PR #3595
Sections
Review

fix(executor): scope legacy empty-branch environment fallback per repository ↗

main ← fix/workspace-reuse-empty-branch-fallback 6 files +62 −8 PR #3595 ↗

This PR scopes the legacy empty-branch fallback per repository so a local environment with both a branch-scoped row and a stale empty-branch row no longer fails the workspace reuse check.

Why this change

A local executor environment can hold a branch-scoped row with no worktree ID and a stale legacy empty-branch worktree row for the same repository. The global fallback check treated the environment as unscoped, so both rows matched the same branch slot and the guard refused the launch with a misleading error.

What it does

Architecture, end to end

Launch admission validates the canonical inventory before reuse. The per-repository check now decides whether the legacy empty-branch row may stand in for a branch slot.

flowchart LR
  Req[LaunchAgentRequest] --> Validate[validateReuseEnvironmentInventory]
  Validate --> List[ListTaskEnvironmentRepos]
  Validate --> Match[canonicalInventoryMatches]
  Match --> PerRepo[repositoryHasBranchScopedRepoRow]
  PerRepo --> Decision{match == 1?}
  Decision -- yes --> Reuse[reuseExistingEnvironment]
  Decision -- no --> Refuse[ErrWorkspaceReuseUnsafe]
  Reuse --> Worktree[reuseExistingRepositoryWorktrees]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func canonicalInventoryMatches(spec RepoSpec, rows []*models.TaskEnvironmentRepo, useWorktree bool) int
Click for details →

The function now checks per repository whether a branch-scoped row exists, so the legacy empty-branch row only matches when the same repository has no scoped row.

Before and after
	matches := 0
	expectedBranchSlug := launchRepoBranchIdentitySlug(spec)
	allowLegacyEmptyBranch := expectedBranchSlug != "" && !hasBranchScopedEnvironmentRepoRows(rows)
	allowLegacyEmptyBranch := expectedBranchSlug != "" && !repositoryHasBranchScopedRepoRow(rows, spec.RepositoryID)
	for _, row := range rows {
		branchMatches := worktree.SanitizeBranchSlug(row.BranchSlug) == expectedBranchSlug
func repositoryHasBranchScopedRepoRow(repos []*models.TaskEnvironmentRepo, repositoryID string) bool
Click for details →

The helper reports branch scope for one repository without requiring a worktree ID, which lets local executor rows count as scoped.

New helper
// repositoryHasBranchScopedRepoRow reports whether repos contains a row for
// repositoryID with a non-empty sanitized branch slug. WorktreeID is not
// required because local executor rows can be branch-scoped without a worktree.
func repositoryHasBranchScopedRepoRow(repos []*models.TaskEnvironmentRepo, repositoryID string) bool {
	for _, repo := range repos {
		if repo.RepositoryID == repositoryID && worktree.SanitizeBranchSlug(repo.BranchSlug) != "" {
			return true
		}
	}
	return false
}
func TestValidateReuseEnvironmentInventory_ScopedBranchPlusLegacyEmptyRowAttaches(t *testing.T)
Click for details →

The test proves the guard now attaches when a repository has both a main row and a legacy empty-branch row.

Regression test
func TestValidateReuseEnvironmentInventory_ScopedBranchPlusLegacyEmptyRowAttaches(t *testing.T) {
	repo := newMockRepository()
	repo.taskRepositories["task-repo-1"] = &models.TaskRepository{ID: "task-repo-1", TaskID: "task-1", RepositoryID: "repo-1"}
	e := newTestExecutor(t, &mockAgentManager{}, repo)
	req := &LaunchAgentRequest{
		TaskID:                 "task-1",
		WorkspaceReuseRequired: true,
		Repositories: []RepoSpec{
			{RepositoryID: "repo-1", BranchIdentitySlug: "main"},
		},
	}
	env := &models.TaskEnvironment{ID: "env-1"}
	repo.taskEnvironmentRepos[env.ID] = []*models.TaskEnvironmentRepo{
		{RepositoryID: "repo-1", BranchSlug: "main", WorktreeID: ""},
		{RepositoryID: "repo-1", BranchSlug: "", WorktreeID: "worktree-legacy"},
	}

	if err := e.validateReuseEnvironmentInventory(context.Background(), req, env); err != nil {
		t.Fatalf("validateReuseEnvironmentInventory() = %v, want nil", err)
	}
}
func TestCanonicalInventoryMatches_ScopedLocalBranchSuppressesLegacyFallback(t *testing.T)
Click for details →

The tests verify that a scoped row suppresses the fallback for its own repository but not for other repositories.

Suppress fallback for same repo
func TestCanonicalInventoryMatches_ScopedLocalBranchSuppressesLegacyFallback(t *testing.T) {
	spec := RepoSpec{RepositoryID: "repo-1", BranchIdentitySlug: "main"}
	rows := []*models.TaskEnvironmentRepo{
		{RepositoryID: "repo-1", BranchSlug: "main", WorktreeID: ""},
		{RepositoryID: "repo-1", BranchSlug: "", WorktreeID: "worktree-legacy"},
	}

	if got := canonicalInventoryMatches(spec, rows, false); got != 1 {
		t.Fatalf("canonicalInventoryMatches() = %d, want 1 (scoped row must suppress legacy fallback)", got)
	}
}
Other repo does not suppress
func TestCanonicalInventoryMatches_ScopedRowOnOtherRepoDoesNotSuppressFallbackForThisRepo(t *testing.T) {
	spec := RepoSpec{RepositoryID: "repo-1", BranchIdentitySlug: "main"}
	rows := []*models.TaskEnvironmentRepo{
		{RepositoryID: "repo-1", BranchSlug: "", WorktreeID: "worktree-legacy"},
		{RepositoryID: "repo-2", BranchSlug: "main", WorktreeID: "worktree-scoped"},
	}

	if got := canonicalInventoryMatches(spec, rows, true); got != 1 {
		t.Fatalf("canonicalInventoryMatches() = %d, want 1 (other repo's scoped row must not suppress repo-1 fallback)", got)
	}
}
Launch admission: canonical inventory match
Click for details →

The spec now states that the empty-branch fallback is per repository and that branch scope does not need a worktree ID.

Spec update
The canonical inventory match for a slot is scoped per repository. The legacy
empty-branch fallback stands in for a repository's branch slot only when that
repository has no row carrying a non-empty branch slug; branch scoping does not
depend on a worktree identifier, so a local-executor row with a branch and no
worktree ID is already branch-scoped and suppresses the fallback for its own
repository. A slot therefore never matches more than one row.
Read the changes as a list

Scope the fallback check per repository

apps/backend/internal/orchestrator/executor/executor_environment_reuse.go

The function now checks per repository whether a branch-scoped row exists, so the legacy empty-branch row only matches when the same repository has no scoped row.

Before and after
	matches := 0
	expectedBranchSlug := launchRepoBranchIdentitySlug(spec)
	allowLegacyEmptyBranch := expectedBranchSlug != "" && !hasBranchScopedEnvironmentRepoRows(rows)
	allowLegacyEmptyBranch := expectedBranchSlug != "" && !repositoryHasBranchScopedRepoRow(rows, spec.RepositoryID)
	for _, row := range rows {
		branchMatches := worktree.SanitizeBranchSlug(row.BranchSlug) == expectedBranchSlug

New per-repository branch-scope helper

apps/backend/internal/orchestrator/executor/executor_environment_reuse.go

The helper reports branch scope for one repository without requiring a worktree ID, which lets local executor rows count as scoped.

New helper
// repositoryHasBranchScopedRepoRow reports whether repos contains a row for
// repositoryID with a non-empty sanitized branch slug. WorktreeID is not
// required because local executor rows can be branch-scoped without a worktree.
func repositoryHasBranchScopedRepoRow(repos []*models.TaskEnvironmentRepo, repositoryID string) bool {
	for _, repo := range repos {
		if repo.RepositoryID == repositoryID && worktree.SanitizeBranchSlug(repo.BranchSlug) != "" {
			return true
		}
	}
	return false
}

Guard test for scoped plus legacy rows

apps/backend/internal/orchestrator/executor/executor_environment_reuse_inventory_test.go

The test proves the guard now attaches when a repository has both a main row and a legacy empty-branch row.

Regression test
func TestValidateReuseEnvironmentInventory_ScopedBranchPlusLegacyEmptyRowAttaches(t *testing.T) {
	repo := newMockRepository()
	repo.taskRepositories["task-repo-1"] = &models.TaskRepository{ID: "task-repo-1", TaskID: "task-1", RepositoryID: "repo-1"}
	e := newTestExecutor(t, &mockAgentManager{}, repo)
	req := &LaunchAgentRequest{
		TaskID:                 "task-1",
		WorkspaceReuseRequired: true,
		Repositories: []RepoSpec{
			{RepositoryID: "repo-1", BranchIdentitySlug: "main"},
		},
	}
	env := &models.TaskEnvironment{ID: "env-1"}
	repo.taskEnvironmentRepos[env.ID] = []*models.TaskEnvironmentRepo{
		{RepositoryID: "repo-1", BranchSlug: "main", WorktreeID: ""},
		{RepositoryID: "repo-1", BranchSlug: "", WorktreeID: "worktree-legacy"},
	}

	if err := e.validateReuseEnvironmentInventory(context.Background(), req, env); err != nil {
		t.Fatalf("validateReuseEnvironmentInventory() = %v, want nil", err)
	}
}

Unit tests for the match logic

apps/backend/internal/orchestrator/executor/executor_environment_test.go

The tests verify that a scoped row suppresses the fallback for its own repository but not for other repositories.

Suppress fallback for same repo
func TestCanonicalInventoryMatches_ScopedLocalBranchSuppressesLegacyFallback(t *testing.T) {
	spec := RepoSpec{RepositoryID: "repo-1", BranchIdentitySlug: "main"}
	rows := []*models.TaskEnvironmentRepo{
		{RepositoryID: "repo-1", BranchSlug: "main", WorktreeID: ""},
		{RepositoryID: "repo-1", BranchSlug: "", WorktreeID: "worktree-legacy"},
	}

	if got := canonicalInventoryMatches(spec, rows, false); got != 1 {
		t.Fatalf("canonicalInventoryMatches() = %d, want 1 (scoped row must suppress legacy fallback)", got)
	}
}
Other repo does not suppress
func TestCanonicalInventoryMatches_ScopedRowOnOtherRepoDoesNotSuppressFallbackForThisRepo(t *testing.T) {
	spec := RepoSpec{RepositoryID: "repo-1", BranchIdentitySlug: "main"}
	rows := []*models.TaskEnvironmentRepo{
		{RepositoryID: "repo-1", BranchSlug: "", WorktreeID: "worktree-legacy"},
		{RepositoryID: "repo-2", BranchSlug: "main", WorktreeID: "worktree-scoped"},
	}

	if got := canonicalInventoryMatches(spec, rows, true); got != 1 {
		t.Fatalf("canonicalInventoryMatches() = %d, want 1 (other repo's scoped row must not suppress repo-1 fallback)", got)
	}
}

Spec clarifies per-repository fallback

docs/specs/tasks/system-design/additional-session-workspace-reuse.md

The spec now states that the empty-branch fallback is per repository and that branch scope does not need a worktree ID.

Spec update
The canonical inventory match for a slot is scoped per repository. The legacy
empty-branch fallback stands in for a repository's branch slot only when that
repository has no row carrying a non-empty branch slug; branch scoping does not
depend on a worktree identifier, so a local-executor row with a branch and no
worktree ID is already branch-scoped and suppresses the fallback for its own
repository. A slot therefore never matches more than one row.

Data and storage

The canonical inventory lives in task_environment_repos. Each row is keyed by repository and branch slug.

FieldTypeNotes
RepositoryIDstringrepository that owns the row
BranchSlugstringsanitized branch slug; empty for legacy rows
WorktreeIDstringworktree identifier; empty for local executor rows
Statusenumactive, failed, or deleted; failed and deleted rows do not count

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Change is narrow: one predicate and one helper in the reuse guard.
  • Existing tests for legacy-only and mismatched inventories still pass.
  • No schema change and no worktree-ID reuse path change.

Trade-offs and review notes

Where to look first

  1. Check canonicalInventoryMatches: the fallback now uses repositoryHasBranchScopedRepoRow with spec.RepositoryID.
  2. Check repositoryHasBranchScopedRepoRow: it must ignore WorktreeID and use SanitizeBranchSlug.
  3. Confirm the two new match tests and the inventory guard test cover the scoped-plus-legacy case.
  4. Verify the spec update matches the code: per-repository fallback and worktree-ID independence.