Skip to content

A failed task discards commits the agent already made; every run leaves an unreapable branch #683

Description

@EnmaJim

What happened

Two related lifecycle problems, both about work and branches that outlive — or fail to outlive — a run.

(1) A failed task's committed work is discarded and is not in the reflog.

With quality_gate.enabled = true and a deliberately failing test_command, a real agent run created
its file, committed it, then called task_set_status(done). The gate correctly refused:

mcp__dotbot__task_set_status: MCP error -32603: Tool execution failed: validation error:
Hook 'enter-done' aborted the transition: Verify '05-verify-quality.ps1' failed:
Quality gate failed: test (exit 1)

Task ended failed. Then the worktree and task branch were deleted, taking the commit with them:

qg-probe.ts present on:
  master                     : False
  develop                    : False
  feature/qa-work            : False
  workflow/fast-prompt-duJ1  : False     (integration branch — correct for a failure)
worktrees preserved : 0
task branch         : gone

Recoverability:

git reflog --all | grep qg-probe        -> not in reflog     (branch deleted, its reflog went too)
git stash list                          -> (empty)
git fsck --lost-found                   -> 8 dangling commits, one of which is the work:
  55f3b6b  feat: add QG_PROBE constant for quality gate probe

So the code exists only as a dangling git object, outside the reflog, and the next git gc destroys
it. The quality gate's entire purpose is to catch code that fails tests — and the failure path then
throws that code away, so the developer cannot inspect or fix it and must pay for the work again.

The inconsistency is notable: the merge-failure path deliberately preserves everything and says so
("Worktree preserved at: …"), while the task-failure path deletes it. The two paths disagree about
whether in-flight work is worth keeping.

(2) Every run leaves a workflow/* branch that the supplied reaper cannot see.

Three successive smoke-test runs on one project:

local  workflow/* : 3        remote workflow/* : 3
workflow/smoke-test-Ikka  ahead=0  sha=af3b80c
workflow/smoke-test-MhFg  ahead=0  sha=af3b80c
workflow/smoke-test-a09g  ahead=0  sha=af3b80c
git diff --stat master..workflow/smoke-test-a09g   ->   (empty)

All three are byte-identical to master — the workflow's only output is gitignored, so the
squash-merge replayed an empty patch. Each was still pushed to the remote and announced:

Integration branch workflow/smoke-test-a09g pushed to <origin>. Open a PR into master.

A PR from any of them would be empty. Branches are produced on the failure path too — a run that
completed zero tasks still pushed one (see #682's logs).

dotbot prune-branches cannot clean them with any documented invocation:

Attempt Result
prune-branches --dry-run ✓ No branches match the prune criteria. (window 30d)
prune-branches --dry-run --include-remote ✓ No branches match the prune criteria.
prune-branches --older-than 0d --dry-run A parameter cannot be found that matches parameter name 'older-than'. exit 0
prune-branches -OlderThan 0d --dry-run ✓ No branches match… (skipped: they have upstreams)
prune-branches -OlderThan 0d --include-remote --dry-run 3 candidates

Two defaults conspire. Get-PrunableBranches:514 skips any branch with has_remote_ref unless
-IncludeRemote, and _Get-RepoBranchRecords:566 derives that from %(upstream:short) — so pushing
the branch is what makes it invisible
to the default prune. Widening the 30-day window then requires
-OlderThan, whose documented kebab-case form is unbindable (see BUG-006). Only the undocumented
PowerShell-native -OlderThan works.

A related correctness detail: _Get-RepoBranchRecords:547 measures age from the branch tip's
%(committerdate), not from when the branch was created. An empty integration branch inherits the
trunk's tip date, so on a repo whose trunk is older than 30 days brand-new branches are immediately
prunable, and on an actively committed trunk they never are. Branch age is not what is measured.

What you expected

  1. Work an agent has committed should survive a task failure — preserve the task branch (as the
    merge-failure path already does), or at minimum name the recoverable commit in the failure message.
    For a quality-gate failure specifically, keeping the branch is the whole point: someone has to fix
    the code.
  2. A completed run should reap its own integration branch when it is empty, or the default
    dotbot prune-branches should be able to see the branches Dotbot itself creates — without requiring
    an undocumented parameter spelling plus a non-default flag.

Steps to reproduce

(1) Discarded work on a failed task (needs an AI provider):

$env:DOTBOT_HOME = '<dotbot checkout>'
# init a repo + dotbot init + `workflow add fast-prompt` as usual, then:
@'
{ "workflow": "fast-prompt",
  "quality_gate": { "enabled": true,
    "test_command": "pwsh -NoProfile -Command \"exit 3\"", "lint_command": null } }
'@ | Set-Content .bot\.control\settings.json

# launch a code task over HTTP (the CLI cannot pass a prompt):
& $env:DOTBOT_HOME\bin\dotbot.ps1 go        # note the port from .bot\.control\ui-port
Invoke-RestMethod -Method Post -Uri "http://localhost:<port>/api/workflows/fast-prompt/run" `
  -ContentType 'application/json' -Headers @{'X-Dotbot-Request'='1'} `
  -Body '{"prompt":"Create apps/web/src/lib/qg-probe.ts exporting QG_PROBE = \"x\". Nothing else."}'

# after it settles: task status = failed, and the committed file is on no branch
git for-each-ref --format='%(refname:short)' refs/heads/ |
  ForEach-Object { "$_ : $(git cat-file -e "${_}:apps/web/src/lib/qg-probe.ts" 2>$null; $LASTEXITCODE -eq 0)" }
git reflog --all | Select-String qg-probe        # -> nothing
git fsck --lost-found | Select-String 'dangling commit'   # -> the work is here only

(2) Unreapable branch debris (no AI provider needed):

& $env:DOTBOT_HOME\bin\dotbot.ps1 workflow add smoke-test -y
1..3 | ForEach-Object { & $env:DOTBOT_HOME\bin\dotbot.ps1 run smoke-test --watch }

git branch --list 'workflow/*'                     # -> 3 branches
git branch -r --list 'origin/workflow/*'           # -> 3 on the remote too
git rev-list --count master..workflow/smoke-test-XXXX   # -> 0, they are empty

& $env:DOTBOT_HOME\bin\dotbot.ps1 prune-branches --dry-run                    # -> none
& $env:DOTBOT_HOME\bin\dotbot.ps1 prune-branches --older-than 0d --dry-run    # -> binding error, exit 0
& $env:DOTBOT_HOME\bin\dotbot.ps1 prune-branches -OlderThan 0d --include-remote --dry-run   # -> finally lists them

Environment

OS: Windows 11 Pro 10.0.26200 | dotbot v4.0.2 (main @ 7c95b466)
pwsh 7.6.4 | git 2.54.0.windows.1 | claude CLI 2.1.222 (models: shipped defaults, all resolve)
DOTBOT_HOME set explicitly; %APPDATA%\dotbot\user-settings.json absent
Target repo: 1129 files, single remote (local bare origin), master default

Severity

high

Logs / screenshots

Cosmetic defect visible in the prune output that finally works — branch name and timestamp are
concatenated with no separator:

  CANDIDATES
  ────────────────────────────────────────────
      • workflow/smoke-test-Ikka08/05/2026 15:37:47 [has remote]
      • workflow/smoke-test-MhFg08/05/2026 15:37:47 [has remote]
      • workflow/smoke-test-a09g08/05/2026 15:37:47 [has remote]

Reap command that does work today, for anyone accumulating debris now:

dotbot prune-branches -OlderThan 0d --include-remote --dry-run   # review first
dotbot prune-branches -OlderThan 0d --include-remote -y

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Status
    Inbox

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions