Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions agent/src/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,9 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup:
for pred_branch in config.merge_branches:
_merge_predecessor_branch(repo_dir, pred_branch, notes)
elif config.base_branch:
# #247 A4: stacked child. Branch from the predecessor's branch
# (linear) or from main (diamond) so the child sees predecessor
# code without waiting for a human merge. fetch the base first —
# #247 A4: LINEAR stacked child. Branch from the single predecessor's
# branch so the child sees the predecessor's code without waiting for a
# human merge, and its PR shows only its OWN diff. fetch the base first —
# it is an unmerged sibling branch that the fresh clone may not
# have locally.
log("SETUP", f"Creating branch {branch} from base {config.base_branch}")
Expand Down Expand Up @@ -292,14 +292,28 @@ def setup_repo(config: TaskConfig, progress: Any = None) -> RepoSetup:
log("SETUP", f"Base branch not found; creating {branch} off HEAD")
run_cmd(["git", "checkout", "-b", branch], label="create-branch", cwd=repo_dir)

# Diamond: merge each predecessor branch into this child's branch
# so it sees ALL predecessors' code (the base only gave it one).
# A linear stack may still carry merge_branches on the restack path;
# merge each so the child sees ALL predecessors' code.
for pred_branch in config.merge_branches:
_merge_predecessor_branch(repo_dir, pred_branch, notes)
else:
# Root OR diamond (#247 A4 / ABCA-688). Branch off the cloned HEAD,
# which is the repo's REAL default branch (``gh repo clone`` checks out
# the default). This is deliberately NOT pinned to 'main': pinning a
# stale 'main' as the diamond/integration base — on a repo whose default
# is a different branch — made the epic's integration PR diff the entire
# branch divergence (ABCA-688: 100 commits / 700+ files). Branching off
# the real default keeps the PR base correct so it shows only the
# actual changes.
log("SETUP", f"Creating branch: {branch}")
run_cmd(["git", "checkout", "-b", branch], label="create-branch", cwd=repo_dir)

# Diamond fan-in: merge each predecessor branch into this child's branch
# (off the real default) so it sees ALL predecessors' code. Empty for a
# true root — the loop is then a no-op and behavior is unchanged.
for pred_branch in config.merge_branches:
_merge_predecessor_branch(repo_dir, pred_branch, notes)

# Trust mise config files in the cloned repo (required before mise install
# AND before every `mise run <task>`). ``mise trust <dir>`` trusts only the
# ROOT ``mise.toml`` — but a monorepo has per-package config ROOTS
Expand Down
54 changes: 54 additions & 0 deletions agent/tests/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,60 @@ def fake_run(*args, **kwargs):
assert repo.detect_default_branch("owner/repo", "/tmp/x") == "main"


class TestDiamondBranchesOffRealDefault:
"""ABCA-688: the diamond / synthetic integration child must branch off the
repo's REAL default branch (the cloned HEAD), NOT a pinned 'main', and still
merge every predecessor. Pinning 'main' as the diamond base on a repo whose
default is a different long-lived branch made the epic's integration PR diff
the entire branch divergence (100 commits / 700+ files). The platform now
OMITS base_branch for the diamond and threads only merge_branches; repo.py
must fall into the off-HEAD branch AND run the predecessor merges there."""

def test_diamond_no_base_branch_branches_off_head_and_merges_preds(self, monkeypatch):
fake = _fake_run_cmd()
_patch_common(monkeypatch, fake)
# The repo's real default is NOT 'main' — mirrors the failing fork.
monkeypatch.setattr(repo, "detect_default_branch", lambda url, d: "linear-vercel")

setup = repo.setup_repo(
_config(
is_pr_workflow=False,
branch_name="bgagent/01INTTASK/orch-integration",
# Diamond: no base_branch, but predecessor branches to merge.
base_branch=None,
merge_branches=["bgagent/taskB/b", "bgagent/taskC/c"],
)
)

labels = fake.labels()
# Branched off the cloned HEAD (the real default) — NOT from a pinned base.
assert "create-branch" in labels
assert "create-branch-from-base" not in labels
# Every predecessor branch was fetched + merged so the child sees all code.
merge_cmds = [c["cmd"] for c in fake.calls if "merge-predecessor" in c["label"]]
merged_refs = {cmd[-1] for cmd in merge_cmds}
assert merged_refs == {"origin/bgagent/taskB/b", "origin/bgagent/taskC/c"}
# The PR base + commit-diff range is the real default, not 'main'.
assert setup.default_branch == "linear-vercel"

def test_linear_child_still_pins_predecessor_base(self, monkeypatch):
# A single-predecessor (linear) child keeps stacking on its predecessor's
# branch — that path is correct and unchanged by the ABCA-688 fix.
fake = _fake_run_cmd()
_patch_common(monkeypatch, fake)
setup = repo.setup_repo(
_config(
is_pr_workflow=False,
branch_name="bgagent/01CHILD/step-b",
base_branch="bgagent/taskA/step-a",
)
)
labels = fake.labels()
assert "create-branch-from-base" in labels
# base_branch wins as the PR base for a linear stack (no detection call).
assert setup.default_branch == "bgagent/taskA/step-a"


class TestPlatformBranchNameVerbatim:
"""The agent MUST use the platform-provided ``config.branch_name`` verbatim
when present, for EVERY workflow — never re-deriving its own slug. A
Expand Down
21 changes: 18 additions & 3 deletions cdk/src/handlers/shared/orchestration-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,24 @@ export async function releaseReadyChildren(
...(releaseContext.channel_source !== undefined && {
channelSource: releaseContext.channel_source as ChannelSource,
}),
// Root → 'main' base, no merges (omit so today's off-main behavior
// is unchanged). Linear → predecessor branch. Diamond → main + merges.
...(selection.shape !== 'root' && { baseBranch: selection.base_branch }),
// Linear (single predecessor) → pin the child's base to that
// predecessor's branch so it stacks and its PR shows only its OWN diff.
// Root AND diamond → DO NOT pin a base branch: omit it so the agent
// detects the repo's REAL default branch and branches off it.
//
// ABCA-688: pinning ``selection.base_branch`` (which is the hardcoded
// ``defaultBranch``, historically 'main') for the diamond / synthetic
// integration node was the epic-PR bloat bug. On a repo whose default
// branch is NOT 'main' (e.g. a fork whose default is an integration
// branch), the integration node branched off a stale 'main', merged the
// default-branch-based leaf branches into it, and opened its PR against
// 'main' — so the diff was the ENTIRE branch divergence (100 commits /
// 700+ files) rather than the handful of files actually changed. Roots
// already omitted the base (and produced correctly-scoped PRs), which is
// why single-issue tasks were fine and only the epic flow bloated. The
// diamond still sees every predecessor's code via ``mergeBranches`` (the
// agent merges them onto the detected-default HEAD).
...(selection.shape === 'linear' && { baseBranch: selection.base_branch }),
...(selection.merge_branches.length > 0 && { mergeBranches: selection.merge_branches }),
createTaskCore,
now,
Expand Down
62 changes: 62 additions & 0 deletions cdk/test/handlers/shared/orchestration-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,68 @@ describe('releaseReadyChildren — #331 concurrency throttle', () => {
});
});

describe('releaseReadyChildren — ABCA-688: base branch threading by shape', () => {
function createOk() {
let i = 0;
return jest.fn().mockImplementation(() =>
Promise.resolve({ statusCode: 201, body: JSON.stringify({ data: { task_id: `T-${i++}` } }) }));
}

test('LINEAR child (one predecessor) pins its base to the predecessor branch', async () => {
const ddb = { send: jest.fn().mockResolvedValue({}) };
const createTaskCore = createOk();
const pred = makeRow({ sub_issue_id: 'A', child_status: 'succeeded', child_branch_name: 'bgagent/taskA/a' });
const child = makeRow({ sub_issue_id: 'B', depends_on: ['A'], child_status: 'ready' });
await releaseReadyChildren(
ddb as never, 'OrchTable', [child], { platform_user_id: 'u1' } as never,
createTaskCore as never, NOW, [pred, child], 'main', undefined,
);
const ctx = createTaskCore.mock.calls[0][1];
expect(ctx.channelMetadata.orchestration_base_branch).toBe('bgagent/taskA/a');
// Linear stack carries no merge list (it stacks directly).
expect(ctx.channelMetadata.orchestration_merge_branches).toBeUndefined();
});

test('DIAMOND / integration child (2+ predecessors) does NOT pin a base branch — regression for the huge epic PR', async () => {
// ABCA-688: pinning the hardcoded default ('main') as the diamond base made
// the integration PR diff the whole branch divergence on a repo whose real
// default is not 'main'. The diamond must OMIT the base so the agent
// branches off the repo's detected default, and merge predecessors instead.
const ddb = { send: jest.fn().mockResolvedValue({}) };
const createTaskCore = createOk();
const b = makeRow({ sub_issue_id: 'B', child_status: 'succeeded', child_branch_name: 'bgagent/taskB/b' });
const c = makeRow({ sub_issue_id: 'C', child_status: 'succeeded', child_branch_name: 'bgagent/taskC/c' });
const integration = makeRow({
sub_issue_id: 'orch_abc__integration', depends_on: ['B', 'C'], child_status: 'ready',
});
await releaseReadyChildren(
ddb as never, 'OrchTable', [integration], { platform_user_id: 'u1' } as never,
// A non-'main' default here mirrors the failing repo (default = a
// long-lived integration branch). The bug pinned this as the base.
createTaskCore as never, NOW, [b, c, integration], 'linear-vercel', undefined,
);
const ctx = createTaskCore.mock.calls[0][1];
// The key assertion: NO base branch pinned (agent detects the real default).
expect(ctx.channelMetadata.orchestration_base_branch).toBeUndefined();
// But it still sees every predecessor's code via the merge list.
expect(ctx.channelMetadata.orchestration_merge_branches)
.toBe(JSON.stringify(['bgagent/taskB/b', 'bgagent/taskC/c']));
});

test('ROOT child pins no base branch and carries no merges (unchanged)', async () => {
const ddb = { send: jest.fn().mockResolvedValue({}) };
const createTaskCore = createOk();
const root = makeRow({ sub_issue_id: 'R', depends_on: [], child_status: 'ready' });
await releaseReadyChildren(
ddb as never, 'OrchTable', [root], { platform_user_id: 'u1' } as never,
createTaskCore as never, NOW, [root], 'linear-vercel', undefined,
);
const ctx = createTaskCore.mock.calls[0][1];
expect(ctx.channelMetadata.orchestration_base_branch).toBeUndefined();
expect(ctx.channelMetadata.orchestration_merge_branches).toBeUndefined();
});
});

describe('readConcurrencyBudget — #331', () => {
test('free budget = cap - active_count', async () => {
const ddb = { send: jest.fn().mockResolvedValue({ Item: { active_count: 3 } }) };
Expand Down
Binary file modified cdk/test/integration/orchestration-e2e.test.ts
Binary file not shown.
Loading