Skip to content

244: Enforce issue-owned implementer branches - #247

Open
khaliqgant wants to merge 1 commit into
mainfrom
codex/244-issue-branch-guard
Open

244: Enforce issue-owned implementer branches#247
khaliqgant wants to merge 1 commit into
mainfrom
codex/244-issue-branch-guard

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 14, 2026

Copy link
Copy Markdown
Member

Summary

  • derive an issue-owned factory branch for every live dispatch, including non-durable local fleets
  • reject worktree branches whose issue key differs from the dispatched issue
  • pass the exact expected head through both PR publishers and abort before push or PR creation on mismatch
  • reject mismatched cached, durable, reconciled, and provider-returned PR heads

cloud#3024 diagnosis

The conflict is an artifact of branch reuse. cloud#3024 contains only 2977c744 (3022 Chief org-chart work), while the real 3021 repair is 55f1a42d on cloud#3017, which merged before cloud#3024 opened. There is no unmerged 3021 change to re-cut; creating a replacement would be empty. This change does not delete or force-push factory/3022-chief-org-live-population.

Validation

  • npm run build
  • npm test -- --reporter=dot --silent=true (83 files, 1,556 tests)
  • TruffleHog verified-secret scan (0 findings)

Merge policy: never. Leave open for human review.

Fixes #244


Summary by cubic

Enforces issue-owned implementer branches and blocks PR publication if the head branch does not match the expected Factory branch. Prevents cross‑issue branch reuse and stale shared-checkout heads so PRs and receipts always belong to the dispatched issue. Fixes #244.

  • Every live dispatch now uses an issue-owned branch factory/<issueKey>-<repoSlug>-<8hex>, including non‑durable local fleets. Required: implementers must checkout or push to this exact branch before publishing.
  • Worktree guard rejects pre-created factory/ branches that belong to a different issue. Required: delete/rename the branch or dispatch the matching issue.
  • Publication guard adds expectedHeadRef to PR publish calls and aborts before any push or PR creation if the local or remote head differs; applies to both RelayfileGithubConnectionWrite and GhCliGithubWriteback. Required: remote implementers must push to the expected branch; local publishers must checkout the expected branch.
  • PR receipts and cache consistency are enforced: cached and durable receipts must match the expected head or are rejected; reconciliation only succeeds when the head matches.
  • Matching rules: factoryBranchBelongsToIssue anchors numeric keys (3021 cannot claim 30210) and supports alphanumeric keys (e.g., AR‑244); legacy <issue>-* heads are allowed only when existingPullRequestBranch: true and the issue key is numeric.

Written for commit eaa412e. Summary will update on new commits.

Review in cubic

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@khaliqgant, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 101 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b064a648-785e-4741-b09e-6881b7440b4e

📥 Commits

Reviewing files that changed from the base of the PR and between b27e130 and eaa412e.

📒 Files selected for processing (11)
  • src/git/agent-worktree.test.ts
  • src/git/agent-worktree.ts
  • src/issue-key-match.test.ts
  • src/issue-key-match.ts
  • src/mount/relayfile-github-connection-write.test.ts
  • src/mount/relayfile-github-connection-write.ts
  • src/orchestrator/factory.test.ts
  • src/orchestrator/factory.ts
  • src/ports/mount.ts
  • src/writeback/github.ts
  • src/writeback/writeback.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eaa412ec1e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/issue-key-match.ts
if (!normalizedHead.startsWith('factory/') || !normalizedKey) return false
return /^\d+$/u.test(normalizedKey)
? normalizedHead === `factory/${normalizedKey}` || normalizedHead.startsWith(`factory/${normalizedKey}-`)
: containsIssueKey(normalizedHead, normalizedKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Anchor alphanumeric issue keys after factory/

For Linear-style keys, this accepts the issue key anywhere in the branch rather than in the ownership position. For example, factory/ar-245-fix-ar-244 is considered owned by AR-244, so a stale branch primarily belonging to AR-245 can pass the new worktree and publication safety gates if it also mentions AR-244 later in its name. Apply the same factory/<issue> or factory/<issue>-... anchoring used for numeric keys.

Useful? React with 👍 / 👎.

@khaliqgant

Copy link
Copy Markdown
Member Author

Review — issue 244 (reviewer: ar-244-review-factory)

Verified independently in a throwaway worktree off eaa412e, not from the diff alone.

Verdict: approve. All four asked-for behaviours are addressed or correctly deferred. Two non-blocking notes and one scope flag below.

Verification performed

Check Result
tsc -p tsconfig.build.json --noEmit clean
Full suite 1555/1556 passed; the single failure is dist-entrypoints.test.ts, which imports dist/ — an artifact of my skipping npm run build, not a code failure. Matches the author's reported 1,556.
Targeted tests (issue-key-match, agent-worktree, writeback, relayfile-github-connection-write) 76/76 passed
Branch provenance single commit off origin/main; b2ad725 is not an ancestor — WIP uncontaminated
factory/3022-chief-org-live-population still at 2977c744, not deleted, not force-pushed
cloud#3024 closed 15:58:07Z

Guards land before the side effect

This was the thing worth checking, and it holds in all three places:

  • writeback/github.ts:91 — the expectedHeadRef mismatch throw sits after headRef derivation but before push HEAD:refs/heads/…. The test asserts ghCalls is empty and exactly one git call was made.
  • relayfile-github-connection-write.ts:53 — throws before headSha and before any mount write; mount.writes asserted empty. Moving the headSha computation below the guards also drops a needless git call on the failure path.
  • agent-worktree.ts:323assertSafeWorktree is the first statement in #prepare, ahead of worktree prune and mkdir, so the regression test's ENOENT assertion on the worktree path is meaningful rather than incidental.

The regression test asked for in the DoD is present and genuine: agent-worktree.test.ts pre-creates factory/3022-chief-org-live-population, dispatches issue 3021, asserts the specific rejection message and that no worktree was created.

The root fix is the right one — implementer.spec.branch was already derived correctly by decisionWithLifecycleBranches and computed at the old :6658 as expectedHeadRef for the reconcile path, but never handed to the publisher. Threading it through and tightening published.headRef !== expectedHeadRef (previously the no-op !== (remoteBranch ?? published.headRef)) closes the hole at the boundary rather than by convention.

Non-blocking findings

1. factoryBranchBelongsToIssue is anchored for numeric keys but not for Linear-style keys (src/issue-key-match.ts:105)

The numeric path is exact (=== factory/<key> or startsWith('factory/<key>-')). The non-numeric path delegates to containsIssueKey, which is an unanchored discovery heuristic — deliberately loose for matching PRs found in the wild. As an enforcement predicate it is too permissive:

factoryBranchBelongsToIssue('factory/ar-999-fix-ar-244', 'AR-244')  // => true

Confirmed by execution, not inspection. Severity is genuinely low: Factory-derived branches are factory/<key>-<repo>-<runid8>, and a repo slug will not contain another issue key, so factory/ar-999-agentworkforce-factory-abcd1234 correctly returns false. It only bites a hand-named or adopted branch reaching assertSafeWorktree. Suggest anchoring both key shapes identically.

2. The durable-receipt guard converts existing corruption into a hard stop (src/orchestrator/factory.ts:6369)

durableReceipt.headRef !== expectedHeadRef now throws where it previously returned the receipt. For lifecycles persisted before this change — whose receipts recorded the stale symbolic-ref head, i.e. precisely the corruption being fixed — this turns a silently-wrong PR into a repeating failure. Failing loudly is the asked-for behaviour so this is not wrong, but it is worth confirming an operator has a lifecycle-reset escape hatch for already-corrupted state.

I checked the obvious re-dispatch concern and it does not apply: #claimDispatchLifecycle is called with preparedRunId, so decisionWithLifecycleBranches is skipped on the seed path, and dispatchDecision is replaced by structuredClone(lifecycleClaim.lifecycle.decision) on takeover. The branch is stable across resume.

3. Scope flag: lifecycleRunId = !dryRun ? randomUUID() : undefined (:2569) now applies decisionWithLifecycleBranches to every live dispatch, including non-durable local fleets that previously received neither a lifecycle branch nor an invocationId. The spec.invocationId ?? … guard at :14255 handles re-entry correctly, but this widens the blast radius past the branch fix and deserves a line in the PR body.

On DoD item 3

Independently corroborated before the PR was opened. cloud#3024 carried exactly one commit — 2977c744, authored 06:26:24Z, ~6h before the 3021 implementer was dispatched — touching organization-hierarchy.ts, which is 3022's subject matter. It contained zero 3021 work, so the issue's premise ("pushed its work onto a branch belonging to a different issue") is not what happened: the implementer pushed nothing and opened a PR on a branch that already existed. The conflict was real but unrelated — the branch forked at b143b3b3, sat 1 ahead / 4 behind, and 43e68b5b (#3018) later touched the same file on main.

Closing as obsolete was correct; a re-cut would have produced an empty PR. I separately confirmed the author's claim that the 3021 repair landed via cloud#3017, merged 06:52:23Z — also before dispatch.

Item 4 (prune stale factory/* branches) is correctly not executed here, per the constraints.

Loose end, outside this PR

cloud#3021 is still OPEN despite its repair having merged via cloud#3017. Worth a separate disposition.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/orchestrator/factory.ts">

<violation number="1" location="src/orchestrator/factory.ts:6346">
P3: The publish legacy gate uses a loose `startsWith(`${record.issue.key.toLowerCase()}-`)`, so malformed legacy branches such as `3021-`, `3021- `, or `3021--foo` pass the ownership gate here, while the worktree safety layer (`isAuthorizedExistingPrBranch`) rejects exactly those forms with a strict `^<key>-[A-Za-z0-9][A-Za-z0-9._-]*$` regex. When an implementer goes through a worktree these gates disagree. Align the publish gate with the worktree regex so the two layers authorize the same set of legacy existing-PR branches.</violation>

<violation number="2" location="src/orchestrator/factory.ts:14255">
P1: When a caller or triage result supplies `spec.invocationId`, this preserves it across a genuine reopen despite generating a new `runId`. `BatchTracker` and the fleet then reuse the old provider idempotency key, which can deduplicate the new spawn against a prior run; always derive the ID from the new run here (takeover already reuses the persisted lifecycle decision).</violation>
</file>

<file name="src/git/agent-worktree.ts">

<violation number="1" location="src/git/agent-worktree.ts:323">
P2: For non-numeric (Linear-style `XXX-N`) issue keys, `factoryBranchBelongsToIssue` falls back to `containsIssueKey`, whose terminal-boundary rule `-(?!\d)` fails whenever the separator following the number is followed by a digit. The worktree branch is always built as `factory/<issue>-<repo>-<runId>` (see `decisionWithLifecycleBranches` in factory.ts), so if the repo slug begins with a digit — e.g. issue `AR-244`, repo `2fa-demo` → branch `factory/ar-244-2fa-demo-abc12345` — `containsIssueKey` returns false and this new check falsely rejects a legitimate Factory branch, throwing in `prepare` and blocking dispatch for that issue. The numeric issue-key path avoids this because it uses an anchored `startsWith('factory/<key>-')`. Mirror that anchored check for the non-numeric path so the separator digit is irrelevant.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +14255 to +14256
invocationId: spec.invocationId ??
`factory:${decision.issue.key}:${runId}:${spec.role}:${sanitizeAgentSlug(spec.name)}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a caller or triage result supplies spec.invocationId, this preserves it across a genuine reopen despite generating a new runId. BatchTracker and the fleet then reuse the old provider idempotency key, which can deduplicate the new spawn against a prior run; always derive the ID from the new run here (takeover already reuses the persisted lifecycle decision).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 14255:

<comment>When a caller or triage result supplies `spec.invocationId`, this preserves it across a genuine reopen despite generating a new `runId`. `BatchTracker` and the fleet then reuse the old provider idempotency key, which can deduplicate the new spawn against a prior run; always derive the ID from the new run here (takeover already reuses the persisted lifecycle decision).</comment>

<file context>
@@ -14225,7 +14252,8 @@ function decisionWithLifecycleBranches(
       // The same persisted lifecycle reuses this id after takeover, while a
       // genuine reopen gets a new id and cannot replay an old placement ack.
-      invocationId: `factory:${decision.issue.key}:${runId}:${spec.role}:${sanitizeAgentSlug(spec.name)}`,
+      invocationId: spec.invocationId ??
+        `factory:${decision.issue.key}:${runId}:${spec.role}:${sanitizeAgentSlug(spec.name)}`,
     }
</file context>
Suggested change
invocationId: spec.invocationId ??
`factory:${decision.issue.key}:${runId}:${spec.role}:${sanitizeAgentSlug(spec.name)}`,
invocationId: `factory:${decision.issue.key}:${runId}:${spec.role}:${sanitizeAgentSlug(spec.name)}`,

Comment thread src/git/agent-worktree.ts
if (target === base || !target.startsWith(`${expectedRoot}/`)) {
throw new Error(`Refusing unsafe Factory worktree path ${target}; expected a child of ${expectedRoot}`)
}
if (worktree.branch.startsWith('factory/') && !factoryBranchBelongsToIssue(worktree.branch, worktree.issueKey)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: For non-numeric (Linear-style XXX-N) issue keys, factoryBranchBelongsToIssue falls back to containsIssueKey, whose terminal-boundary rule -(?!\d) fails whenever the separator following the number is followed by a digit. The worktree branch is always built as factory/<issue>-<repo>-<runId> (see decisionWithLifecycleBranches in factory.ts), so if the repo slug begins with a digit — e.g. issue AR-244, repo 2fa-demo → branch factory/ar-244-2fa-demo-abc12345containsIssueKey returns false and this new check falsely rejects a legitimate Factory branch, throwing in prepare and blocking dispatch for that issue. The numeric issue-key path avoids this because it uses an anchored startsWith('factory/<key>-'). Mirror that anchored check for the non-numeric path so the separator digit is irrelevant.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/git/agent-worktree.ts, line 323:

<comment>For non-numeric (Linear-style `XXX-N`) issue keys, `factoryBranchBelongsToIssue` falls back to `containsIssueKey`, whose terminal-boundary rule `-(?!\d)` fails whenever the separator following the number is followed by a digit. The worktree branch is always built as `factory/<issue>-<repo>-<runId>` (see `decisionWithLifecycleBranches` in factory.ts), so if the repo slug begins with a digit — e.g. issue `AR-244`, repo `2fa-demo` → branch `factory/ar-244-2fa-demo-abc12345` — `containsIssueKey` returns false and this new check falsely rejects a legitimate Factory branch, throwing in `prepare` and blocking dispatch for that issue. The numeric issue-key path avoids this because it uses an anchored `startsWith('factory/<key>-')`. Mirror that anchored check for the non-numeric path so the separator digit is irrelevant.</comment>

<file context>
@@ -319,6 +320,11 @@ const assertSafeWorktree = (worktree: AgentWorktree): void => {
   if (target === base || !target.startsWith(`${expectedRoot}/`)) {
     throw new Error(`Refusing unsafe Factory worktree path ${target}; expected a child of ${expectedRoot}`)
   }
+  if (worktree.branch.startsWith('factory/') && !factoryBranchBelongsToIssue(worktree.branch, worktree.issueKey)) {
+    throw new Error(
+      `Refusing Factory worktree branch ${worktree.branch}: it does not belong to dispatched issue ${worktree.issueKey}`,
</file context>

const matchesCurrentConvention = factoryBranchBelongsToIssue(expectedHeadRef, record.issue.key)
const matchesAuthorizedLegacyBranch = implementer.spec.existingPullRequestBranch === true &&
/^\d+$/u.test(record.issue.key) &&
expectedHeadRef.toLowerCase().startsWith(`${record.issue.key.toLowerCase()}-`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The publish legacy gate uses a loose startsWith(${record.issue.key.toLowerCase()}-), so malformed legacy branches such as 3021-, 3021- , or 3021--foo pass the ownership gate here, while the worktree safety layer (isAuthorizedExistingPrBranch) rejects exactly those forms with a strict ^<key>-[A-Za-z0-9][A-Za-z0-9._-]*$ regex. When an implementer goes through a worktree these gates disagree. Align the publish gate with the worktree regex so the two layers authorize the same set of legacy existing-PR branches.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/orchestrator/factory.ts, line 6346:

<comment>The publish legacy gate uses a loose `startsWith(`${record.issue.key.toLowerCase()}-`)`, so malformed legacy branches such as `3021-`, `3021- `, or `3021--foo` pass the ownership gate here, while the worktree safety layer (`isAuthorizedExistingPrBranch`) rejects exactly those forms with a strict `^<key>-[A-Za-z0-9][A-Za-z0-9._-]*$` regex. When an implementer goes through a worktree these gates disagree. Align the publish gate with the worktree regex so the two layers authorize the same set of legacy existing-PR branches.</comment>

<file context>
@@ -6333,9 +6336,29 @@ export class FactoryLoop implements Factory {
+    const matchesCurrentConvention = factoryBranchBelongsToIssue(expectedHeadRef, record.issue.key)
+    const matchesAuthorizedLegacyBranch = implementer.spec.existingPullRequestBranch === true &&
+      /^\d+$/u.test(record.issue.key) &&
+      expectedHeadRef.toLowerCase().startsWith(`${record.issue.key.toLowerCase()}-`)
+    if (!matchesCurrentConvention && !matchesAuthorizedLegacyBranch) {
+      throw new Error(
</file context>

@khaliqgant

Copy link
Copy Markdown
Member Author

Required: verify against the built CLI, not just unit tests

Do not mark this done on green unit tests alone. Build this repo and exercise the real factory CLI.

Critical context first

The Factory daemon running in production is @agent-relay/factory@0.1.20 (installed Jul 17). This repo is at 0.1.57. Every runtime symptom in issue #244 was observed against 0.1.20 — 37 versions behind the source you are editing.

So before writing a fix, establish that the defect actually exists on current main:

npm ci && npm run build
node bin/factory.mjs --help          # sanity: built CLI runs
node -e "console.log(require('./package.json').version)"   # expect 0.1.57

If the defect does NOT reproduce on a build of current main, stop and say so on the issue. The correct fix is then "release and deploy 0.1.57", not a code change. Reporting that is a success, not a failure — do not invent a change to justify the dispatch.

Reproduce → fix → re-verify

# #244 — implementer must not push to another issue-s branch
node bin/factory.mjs dispatch <ISSUE_KEY> --config <path> --dry-run

Prove: with a branch for a different issue key already present, dispatch refuses it rather than pushing onto it. Reference case: cloud#3024 sits on factory/3022-chief-org-live-population while implementing cloud#3021.

Attach the actual terminal output for the failing run and the passing run. A diff, a type signature, or a green test is not evidence that the CLI behaves correctly.

Notes

  • --dry-run discovers and triages without writes or agent spawns — use it to avoid spawning real agents during verification.
  • Point at an explicit config with --config; Factory resolves exactly one file and does not search.
  • mergePolicy: never — do not merge. Stop at review.

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.

[factory] Implementer pushed to another issue's branch, producing a conflicted PR (cloud#3024)

1 participant