Skip to content

fix(lifecycle): preserve terminal PR association across restore + harden detectPR (closes #1724)#1726

Open
i-trytoohard wants to merge 5 commits into
mainfrom
feat/issue-1724
Open

fix(lifecycle): preserve terminal PR association across restore + harden detectPR (closes #1724)#1726
i-trytoohard wants to merge 5 commits into
mainfrom
feat/issue-1724

Conversation

@i-trytoohard
Copy link
Copy Markdown
Collaborator

Summary

Three layered fixes for #1724, where a restored worker session whose PR had merged got silently re-associated with an unrelated fork PR (in the reported repro: ao-179 (release worker for #1723) ended up polling CI/reviews for fork PR #643 after restore).

Per-commit:

  1. fix(scm-github): drop fork PRs and default-branch sessions from detectPRdetectPR now (a) returns null when session.branch === project.defaultBranch, and (b) requests headRepositoryOwner from gh pr list and discards PRs whose head owner differs from the repo owner. The gh pr list --head <branch> filter previously matched any PR with that head ref name across all forks.
  2. fix(lifecycle): skip detectPR for sessions whose PR is already merged — lifecycle-manager short-circuits scm.detectPR when session.lifecycle.pr.state === "merged". Branch drift between poll cycles (agent runs git checkout main, branch metadata hook fires, lifecycle observes branch=main) was the trigger that let detectPR run against the default branch and pick up unrelated PRs. Closed (unmerged) PRs intentionally still go through detectPR so the existing follow-up-PR rediscovery flow is preserved.
  3. fix(session-manager): refuse to restore sessions with merged/closed PRs — restore now throws SessionNotRestorableError up-front when the session's PR is in a terminal state. Previously restore silently cleared lifecycle.pr.* and reset session.state to working, which combined with branch drift caused the bug. The dead clear-on-restore block has been removed.

Investigation notes

  • Repro metadata at ~/.agent-orchestrator/projects/agent-orchestrator/sessions/ao-179.json showed: branch: "main", lifecycle.pr.number: 643, lifecycle.pr.state: "open", agentReportedPrUrl: ".../pull/1723".
  • lifecycle-manager.ts:657-662 previously guarded detectPR by session.pr truthiness; once repairSessionMetadataOnRead (session-manager.ts:623-665) cleared pr for duplicate-PR-attached sessions, detectPR ran unguarded against session.branch="main" and matched fork PRs (gh pr list --head main returns Fix for #640 ao start fails with "Dependencies not installed" due to npm hoisting of @composio/ao-core  #643 by MayurVirkar and feat: Add first-class container deployment support #415 by zvictor).
  • scm-github/src/index.ts::detectPR requested --head <branch> without owner filtering — the entry point for the fork-match.
  • The exact code path that promoted branch from chore/release-0.6.0"main" is the agent-side metadata hook (agent-claude-code/src/index.ts:188-200) firing on a git checkout main invocation post-merge. It is not pinpointed in this PR because the three fixes here neutralize the impact regardless of how the branch drifted.

Invariants preserved

  • scm-github: detectPR semantics unchanged for non-default branches with same-repo PRs (existing tests still green).
  • lifecycle: sessions with pr.state in {"open", "none"} continue to be auto-detected; the existing closed-PR + branch-switch follow-up-detection test still passes; orchestrator/auto-detect-off opt-outs continue to take precedence.
  • session-manager: isRestorable semantics unchanged for terminated/done/runtime-lost; only the PR-terminal sub-case now refuses with an explicit reason. Workers with pr.state of "open"/"none" still restore normally.

Test plan

  • pnpm typecheck — clean
  • pnpm --filter @aoagents/ao-core test — 1102 unit tests pass; 6 unrelated FTS5 failures stem from a missing better-sqlite3 native binding in the local sandbox (pre-existing, not caused by this PR)
  • pnpm --filter @aoagents/ao-plugin-scm-github test — 172 tests pass
  • New regression tests:
    • scm-github: same-repo PR preferred when both fork and same-repo PRs match; fork-only matches return null; branch === defaultBranch short-circuits without calling gh
    • lifecycle-manager: merged-PR session does not call detectPR
    • session-manager: restoring a session with status: "merged" rejects with /is merged/
  • CI

Closes #1724.

`gh pr list --head <branch>` matches PRs across all forks, so when a
session's branch is the project default (typically "main") we'd match
unrelated contributors' fork PRs. Two defenses:

1. Short-circuit when session.branch === project.defaultBranch — a worker
   session should never legitimately have its head ref on the default
   branch, and matching there is unsafe by construction.
2. Request `headRepositoryOwner` from gh and discard PRs whose head owner
   differs from the project repo owner. Fork PRs are someone else's work.

Refs #1724.
When `session.lifecycle.pr.state === "merged"`, the lifecycle manager now
short-circuits before calling `scm.detectPR`. The merged head branch is
typically deleted on merge, and `session.branch` may drift to the default
branch (e.g. via agent-side `git checkout main` between poll cycles or by
the metadata-update hook). With branch drift, `gh pr list --head main`
matches whatever PRs happen to share that head ref name — including
unrelated fork PRs from other contributors — and the lifecycle would then
overwrite the session's settled merged-PR association with the unrelated
PR's number/url.

Closed (unmerged) PRs intentionally do not short-circuit: an agent that
pivots to a new branch after a closed PR should still rediscover its
follow-up PR via detectPR (existing behavior, exercised by the
"refreshes branch metadata again after a closed PR" test).

Invariants preserved:
- Sessions with `pr.state === "open"` still go through detectPR when they
  have no attached PR yet (orchestrator/auto-detect-off guards unchanged).
- Closed-PR rediscovery on branch switch still works.
- The new guard runs after the existing role/auto-detect/no-branch guards
  so opt-outs continue to take precedence.

Refs #1724.
When a session's PR has reached a terminal state (merged or closed) and
the session is later restored, two things conspire to attach the session
to an unrelated PR:

  1. The original head branch is typically deleted on merge, so on
     workspace restore `session.branch` may drift to the project default
     branch (or be rewritten by an agent-side `git checkout main`).
  2. With branch == default branch, downstream `detectPR` matches
     whatever PR happens to share that head ref name (including fork PRs).

Fix 1 (scm-github) and Fix 2 (lifecycle) defend in depth, but the cleanest
guarantee is to refuse the restore up-front: a session whose PR has
already merged/closed has no remaining work to do. If the user wants
follow-up work, they should spawn a new session.

The previous behavior — silently clearing terminal `lifecycle.pr.*`
fields on restore and resetting `session.state` to "working" — is removed
because it's now unreachable: the new check at step 3a throws before
we get there.

Invariants preserved:
- Workers with `pr.state` of "open"/"none" still restore normally.
- `isRestorable` semantics unchanged for terminated/done/runtime-lost.
- Errors surface via `SessionNotRestorableError`, the same channel used
  by other restore-blockers (workspace missing, OpenCode mapping, etc).

Refs #1724.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Test Coverage Report

Metric Value
Lines covered 1813/1999
Lines not covered 186/1999
Overall coverage 90.7%
Per-file breakdown
File Coverage
packages/core/src/lifecycle-manager.ts 796/865 (92.0%)
packages/core/src/session-manager.ts 1017/1134 (89.7%)

Uncovered lines

  • packages/core/src/lifecycle-manager.ts: L87, L91, L93, L134, L153, L180, L194, L257, L353, L355, L558-L559, L569, L575-L576, L592, L605-L606, L815, L888, L988-L989, L993, L996-L997, L1000, L1066, L1084-L1085, L1101-L1102, L1227, L1258, L1406-L1408, L1508, L1539-L1540, L1546-L1547, L1555, L1564, L1678, L1717, L1836-L1838, L1873-L1875, L1943-L1944, L1967, L2025-L2026, L2030, L2032, L2035, L2118-L2120, L2618, L2743, L2748, L2754, L2759, L2790, L2800
  • packages/core/src/session-manager.ts: L292, L296-L297, L302-L303, L305, L318, L420, L492-L493, L495, L577, L635, L706-L708, L834-L835, L838, L849, L926, L1041-L1043, L1079, L1179, L1292, L1398, L1402-L1403, L1498, L1567-L1568, L1587-L1588, L1602-L1603, L1611-L1612, L1635-L1636, L1662, L1764, L1768-L1769, L1792-L1796, L1798, L1800, L1806, L1830, L1835, L1844-L1845, L1848-L1850, L1857, L1986, L2051-L2053, L2172, L2198-L2199, L2251, L2284-L2285, L2297, L2301, L2353, L2358, L2365, L2374, L2398, L2407-L2408, L2438, L2448, L2454-L2455, L2479, L2500, L2522-L2523, L2529-L2530, L2535, L2547, L2592, L2594-L2596, L2598, L2601-L2603, L2605-L2606, L2608, L2623, L2632, L2640, L2664, L2715, L2719, L2743, L2823, L2826, L2841, L2857, L2860, L2874, L2945

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 7, 2026

Greptile Summary

Three layered defences against the fork-PR mis-association bug (#1724): detectPR now filters to same-repo owners and short-circuits on the default branch; the lifecycle manager skips detectPR entirely for merged sessions; and restore now rejects sessions whose PR has already reached a terminal state instead of silently clearing it.

  • scm-github/detectPR: bumps --limit to 10, fetches headRepositoryOwner, and discards PRs whose head owner differs from the repo owner (strict — null owner is also rejected); adds a prior short-circuit when session.branch === project.defaultBranch.
  • lifecycle-manager: inserts a continue before the existing PR-set guard so that merged-state sessions never reach detectPR, regardless of how session.branch subsequently drifted.
  • session-manager: step 3a throws SessionNotRestorableError for merged/closed PR states up-front during restore, and removes the old clear-on-restore block whose side-effect (resetting pr.state to \"none\") was the root enabler of the bug.

Confidence Score: 5/5

Safe to merge — all three changes are narrowly scoped to the bug path and are backed by new regression tests that directly reproduce the reported scenario.

The three changes work in concert and each is independently correct: the lifecycle guard prevents detectPR from running after branch drift, the session-manager guard prevents silent state-reset on restore, and the scm-github filter prevents fork PR association even if both earlier guards were somehow bypassed. No existing restorable-session paths are affected — only terminal-PR sessions (merged/closed) are newly rejected. Test coverage for the new branches is thorough.

No files require special attention. The two observations in scm-github/src/index.ts (defaultBranch fallback when unconfigured, and undocumented gh ordering for sameRepoPrs[0]) are minor hardening notes, not defects.

Important Files Changed

Filename Overview
packages/plugins/scm-github/src/index.ts detectPR now short-circuits on defaultBranch match and filters gh results to same-repo PRs via headRepositoryOwner; limit bumped to 10 to allow filtering before selection
packages/core/src/lifecycle-manager.ts Adds an early continue for sessions whose lifecycle.pr.state === "merged", preventing detectPR from running after branch drift post-merge
packages/core/src/session-manager.ts New step 3a throws SessionNotRestorableError for merged/closed PR state before restore; removes the old clear-on-restore block that was the root enabler of the bug
packages/plugins/scm-github/test/index.test.ts Adds tests for: null/missing headRepositoryOwner rejection, defaultBranch short-circuit, fork-only null, fork+same-repo same-repo wins; existing tests updated with headRepositoryOwner fixtures
packages/core/src/tests/lifecycle-manager.test.ts Adds regression test verifying detectPR is never called when lifecycle.pr.state === merged and session.branch has drifted to main
packages/core/src/tests/session-manager/restore.test.ts Updates merged-PR test to assert rejection (was asserting success); adds independent closed-PR test that writes lifecycle JSON directly to exercise step 3a's closed branch
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/plugins/scm-github/src/index.ts:615
**Default-branch guard silently inactive when `defaultBranch` is unconfigured**

The guard `if (project.defaultBranch && ...)` is a no-op when `project.defaultBranch` is `undefined` or `""`. In that case a session whose branch drifted to `"main"` (or another default branch name) still proceeds to `gh pr list --head main`. The `headRepositoryOwner` filter provides strong backup protection against fork PRs, but a same-repo PR that coincidentally has `HEAD=main` (e.g. a backport from main) could still be incorrectly auto-associated. A fallback to `"main"` (or the conventional default) when `defaultBranch` is unset would make this guard more reliable independent of project configuration completeness.

### Issue 2 of 2
packages/plugins/scm-github/src/index.ts:664
**`sameRepoPrs[0]` ordering is undocumented**

`gh pr list` returns results in most-recently-updated order by default, so `sameRepoPrs[0]` will be whichever same-repo PR was last touched. In the common case of one active PR per branch this is fine, but if a branch has two open same-repo PRs (e.g. targeting different base branches), the association is ordering-dependent without any tiebreaker. A brief comment noting the reliance on gh's default sort order would make the selection intent clear for future readers.

Reviews (2): Last reviewed commit: "fix(scm-github,session-manager): tighten..." | Re-trigger Greptile

Comment thread packages/plugins/scm-github/src/index.ts
Comment thread packages/core/src/__tests__/session-manager/restore.test.ts
…estore test

Address PR review:

1. Strict-by-default fork filter in `detectPR` — require the head owner
   to be a non-empty string AND match the project repo owner. A null,
   undefined, or empty `headRepositoryOwner.login` (e.g. deleted account
   or future gh API change) no longer silently passes the filter.
2. Update existing same-repo cache/draft tests to include
   `headRepositoryOwner: { login: "acme" }` since the strict filter
   would otherwise reject them as fork PRs.
3. Add a regression test that null/missing `headRepositoryOwner` is
   rejected.
4. Add an independent restore test for the `pr.state === "closed"`
   branch of session-manager's step 3a (previously only `merged` was
   covered).

Refs #1724.
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.

Lifecycle attaches restored worker to unrelated fork PR when session.branch is the default branch

1 participant