PR sessions on agent-authored PRs can't push or edit #61
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#61
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Unattended Forgejo PR sessions can only discuss PRs, even on PRs the agent wrote. They are supposed to drive those PRs: apply requested changes, commit and push.
Observed on lucas/omp-deck#56 (2026-10-22): @lucas picked option 3 for broker config setup, but the session could only post a plan (comment 16604) and not implement it.
Cause: in
apps/server/src/forgejo/dispatcher.tsresolveSession, only a submitted review on an agent-authored PR gets aforgejo_implementersession (write tools, "You maintain pull request #N" prompt). An addressed comment on an agent-authored PR gets aforgejo_pull_requestsession withownPr: true, whose tool list isCONVERSATION_TOOL_NAMES+forgejo_post_review(read-only git, Forgejo read/comment) and whose prompt says "You cannot change labels, state, descriptions, branches or code. Propose such actions in your reply."Decisions:
git_branchmay only create/switch to that branch,git_pushonly toorigin.deck_inbox_*,deck_task_*) are added; Forgejo comments stay the only channel.Plan
Approach
Steps 1–2 are independent; step 3 depends on both; step 4 depends on step 3.
Dispatch API: read a PR's head (
apps/server/src/forgejo/dispatch-api.ts).DispatchApi: and exportinterface PullSummary { headBranch: string; headRepo: string; baseRepo: string }(headRepo/baseRepoareowner/namefull names).createDispatchApilikereadIssue:client.json<RawPull>("GET", repoApiPath(owner, repo, "pulls", String(number)), { signal }), with privateinterface RawPull { head?: { ref?: string; repo?: { full_name?: string } | null } | null; base?: { repo?: { full_name?: string } | null } | null }. Missing strings map to"".readPullto the fake API inapps/server/src/forgejo/dispatcher.test.ts(class around line 80): a public fieldpull: ApiResult<PullSummary> = ok({ headBranch: "agent/feature", headRepo: "lucas/omp-deck", baseRepo: "lucas/omp-deck" })returned byreadPull.Policy: confine PR-maintaining implementer sessions; drop
ownPr(apps/server/src/bridge/session-policy.ts).ForgejoImplementerScope: add optionalheadBranch?: stringwith doc comment: "Head branch ofpull; when set, the session may only create/switch to this branch and push toorigin. Absent for issue-started sessions and older persisted scopes, which stay unconfined." Scopes are free JSON insession_triggers.scope_json(apps/server/src/db/session-triggers.ts), so no migration.forgejoImplementerPolicy.decide, before the generictoolNames.includesallow, whenscope.headBranch !== undefined:git_branch: readactionandnamefrom params.action === "list"→ fall through.action === "create"or"switch"withname === scope.headBranch→ fall through. Anything else (other name,delete) →{ deny: \only the pull request's head branch ${scope.headBranch} may be created or switched to in this session` }`.git_push:remoteparam absent or"origin"→ fall through; else{ deny: "pushes go to origin only in this session" }.git_pushitself needs no branch check: the only local branch the session can create or switch to isheadBranch, andgit_pushrefuses a detached HEAD (apps/server/src/bridge/git-tools/push.ts).forgejoImplementerPolicy.systemContext,pull === numberbranch (PR-only scope) and the linked-issue branch whenheadBranchis set: replace the line "Work on the pull request's branch, commit withgit_commit, push withgit_push. Do not open another pull request." with:- The checkout is a detached HEAD at the pull request's head. Create the local branch `${headBranch}` with `git_branch` (action `create`, no base), commit with `git_commit`, push with `git_push`. You cannot use other branches. Do not open another pull request.and add the line:- When a write-access user asks for a change (in a comment or review), make it, run the relevant checks, commit and push, then reply on #${pull} with what you changed. If the request is ambiguous, ask in a reply instead of guessing.For the linked-issue branch (pull !== number), insert both lines only whenheadBranchis set, after the "You already opened pull request" line, and skip the "Start from … create your own branch" and "open one pull request" lines in that case. WhenheadBranchis undefined, prompts are unchanged.ownPrfromForgejoPullRequestScope, thescope.ownPrdeny inforgejoPullRequestPolicy.decide, and theownPr ?conditional in itssystemContext(keep only the review-instructions line). Persisted rows carryingownPrstay parseable (extra JSON field is ignored).apps/server/src/bridge/session-policy.test.ts: delete cases asserting theownPrdeny/prompt; add cases: withheadBranch: "agent/x",git_branch {action:"create",name:"agent/x"}→"allow",{action:"create",name:"other"}→ deny,{action:"delete",name:"agent/x"}→ deny,{action:"list"}→"allow",git_push {}→"allow",git_push {remote:"fork"}→ deny; withoutheadBranch,git_branch {action:"create",name:"other"}→"allow".Dispatcher: route own-PR comments to a confined implementer session (
apps/server/src/forgejo/dispatcher.ts).Ready(line ~153): replaceownPr: booleanwithprHead: string | null— the head branch when the trigger is on an agent-authored PR (comment or submitted review), else null.returnat ~353): whenrow.isPull && ownPr && !reviewRequest, callapi.readPull(owner, repo, row.number).!ok→await this.fail(job, \could not read the pull request: ${res.status} ${res.message}`, "forgejo", null); return null;`headRepo.toLowerCase() !== row.repo.toLowerCase()→await this.fail(job, \head branch is in ${headRepo}, not ${row.repo}`, "fork-head", null); return null;`headBranch === ""→this.fail(job, "the pull request has no head branch", "forgejo", null); return null.prHead = headBranch."fork-head"toFailureKind;failureLabelreturns"this pull request's head branch is in another repository, so the agent cannot push to it"for it.resolveSession: replaceconst forReview = ready.reason === "review-submitted"withconst maintain = ready.prHead !== null. Usemaintainfor the trigger choice, the session name prefix ("Implement"), andlinkedIssue.linkedIssue=extractLinkedIssues(ready.payload.pull_request?.body ?? ready.payload.issue?.body)[0] ?? null(addbody?: string | nulltoIssueCommentPayloadLike.issueinapps/server/src/forgejo/conversation-detect.ts). The implementer scope gainsheadBranch: ready.prHead. Non-maintain PR triggers build{ kind: "forgejo_pull_request", scope: { owner, repo, number: row.number } }.reuseMapped: change the check at ~511 toif (ready.prHead !== null && trigger.kind !== "forgejo_implementer") return "it is not an implementer session";and update the comment above it ("Acting on the agent's own PR needs the implementer's write tools.").runReadyseed selection is unchanged: own-PR comments still usebuildConversationSeed.dispatcher.test.ts:kind: "forgejo_implementer", scope{ owner, repo, number: 9, baseBranch: null, pull: 9, headBranch: "agent/feature" }, session nameImplement: lucas/omp-deck#9.ownPrfrom the expectedforgejo_pull_requestscopes (~388, ~431) and theinsertSessionTriggerfixture (~1074).headBranch: "agent/feature".forgejo_pull_requestsession → new implementer session replaces the mapping (mirror the test at ~1070 using a comment trigger).h.api.pull = ok({ headBranch: "x", headRepo: "alice/omp-deck", baseRepo: "lucas/omp-deck" })→ rowfailed, no session, posted comment containshead branch is in another repository.h.api.pullnot ok → rowfailed, posted comment containsa Forgejo API call failed.forgejo_pull_request,readPullnot called.baseBranchfor comment triggers: usenull(issue_comment payloads carry no base ref); the PR-maintaining prompt never uses it.Docs: update any docs/KB text describing own-PR sessions as read-only (
grep -rn "ownPr\|own pull request" docs apps/server/src— touch only prose that states the old behavior).Critical files & anchors
apps/server/src/forgejo/dispatcher.ts— gate (~270–353),resolveSession(~427),reuseMapped(~491),FailureKind/failureLabel(~115, ~653).apps/server/src/bridge/session-policy.ts—ForgejoImplementerScope,forgejoImplementerPolicy.decide/systemContext,forgejoPullRequestPolicy.apps/server/src/forgejo/dispatch-api.ts—DispatchApi,createDispatchApi.apps/server/src/forgejo/checkout.ts— PR checkouts are detached atrefs/pull/<n>/head; this is why the session mustgit_branch createthe head branch before pushing.Verification
cd apps/server && bun test src/bridge/session-policy.test.ts src/forgejo/dispatcher.test.ts— all pass, including the new cases above.bun run typecheck(repo root) — no references toownPrremain:grep -rn ownPr apps/server/srcreturns nothing.lucas/omp-deck, comment@<agent> please rename X to Y. Expected: a session namedImplement: lucas/omp-deck#<n>starts; it creates the local head branch, commits, pushes a new commit to the PR's head branch, and replies on the PR. In the deck, the session's tool list includesedit,bash,git_commit,git_push. On a human-authored PR, the same comment yields a reply-only session.Assumptions & contingencies
GET /repos/{owner}/{repo}/pulls/{n}response lackshead.repo(e.g. the head repo was deleted),headRepois"", which does not matchrow.repoand fails asfork-head. Accepted: such a PR can't be pushed to anyway.extractLinkedIssuesfinds an issue in the PR body for a comment trigger, the issue mapping follows the existing review-path rule (an issue owned by another usable session keeps it).