ward agent: an empty repo (no origin/main) forces a clean run into salvage instead of establishing main #599

Closed
opened 2026-07-04 08:14:54 +00:00 by coilysiren · 6 comments
Owner

Problem

A ward agent engineer run dispatched at a brand-new empty repo (no origin/main) always degrades to a salvage branch, even when the run finished cleanly. The engineer does the work, tries to push main, and the push fails because there is no base ref, so the reaper preserves everything on ward-salvage/<id> and reopens the issue. CI never fires (it triggers on push to main, which was never created), and a human has to promote the salvage branch by hand.

Live instance (2026-07-04)

coilyco-flight-deck/reddit-mcp#1. The repo was created empty (no auto-init), an engineer built the full source (FastMCP server, tests, Dockerfile, .forgejo build-publish CI, the trifecta - a genuinely complete tree), but the reaper produced:

origin/main:       -
ancestry:          origin/main absent - cannot compute ancestry
decision gate:     no origin/main to integrate against
reason:            push to main failed
run-owned landed:  no
working tree:      15 dirty path(s)

Recovery was a manual git push origin <salvage>:main by the operator, which then fired CI. The run itself was fine - the only defect was the missing base branch.

Why this is worth fixing in ward, not just "always auto-init the repo"

Creating the repo with an initial commit before dispatch is the current workaround, but it is a manual step that is easy to skip (and was skipped here precisely because org-repo creation sat above the surface-session token tier, so it fell to a hand-create that omitted auto-init). ward already owns the land/reap decision, so it can handle the empty-repo case deterministically instead of relying on every dispatcher remembering to pre-seed main.

Proposed behavior

When the reaper (or the engineer at land time) finds origin/main absent:

  • Treat "no base branch" as an establish-main case, not a salvage case. If the run produced a clean, run-owned commit, push it as main (create the default branch from the run's work) rather than diverting to ward-salvage/<id>. That both lands the feature and initializes the repo in one step, and the push fires CI normally.
  • Only fall back to the salvage branch if the work is genuinely unlandable for a real reason (dirty/ambiguous tree, conflicting concurrent main that appeared, push rejected by branch protection) - i.e. the salvage path should key off an actual failure, not off the benign "the repo was empty" condition.
  • If ward would rather not have the reaper create default branches, the alternative is to pre-flight it: ward agent refuses to dispatch at a repo with no origin/main (or auto-seeds a main with an empty root commit before the container starts), so the container always has a base to land on. Either the reap-side or the dispatch-side fix removes the failure class.

Acceptance

  • An engineer run dispatched at an empty repo lands its work on main and fires CI, with no manual salvage-branch promotion, OR is refused/pre-seeded at dispatch with a clear message. No silent salvage of a clean run just because the repo started empty.
  • The origin/main absent reap diagnostic no longer maps to reason: push to main failed for an otherwise-clean run.

Refs

  • Live instance and the manual recovery: coilyco-flight-deck/reddit-mcp#1 (reaper reopen comment carries the full diagnostics).
  • Related reaper/salvage doctrine: the container-agent reaper backstop (commit-loose -> integrate-onto-main -> push-or-salvage).

Provenance: director surface session, 2026-07-04 - caught while dispatching the reddit-mcp source build.

## Problem A `ward agent` engineer run dispatched at a **brand-new empty repo** (no `origin/main`) always degrades to a salvage branch, even when the run finished cleanly. The engineer does the work, tries to push `main`, and the push fails because there is no base ref, so the reaper preserves everything on `ward-salvage/<id>` and reopens the issue. CI never fires (it triggers on push to `main`, which was never created), and a human has to promote the salvage branch by hand. ## Live instance (2026-07-04) `coilyco-flight-deck/reddit-mcp#1`. The repo was created empty (no auto-init), an engineer built the full source (FastMCP server, tests, Dockerfile, `.forgejo` build-publish CI, the trifecta - a genuinely complete tree), but the reaper produced: ``` origin/main: - ancestry: origin/main absent - cannot compute ancestry decision gate: no origin/main to integrate against reason: push to main failed run-owned landed: no working tree: 15 dirty path(s) ``` Recovery was a manual `git push origin <salvage>:main` by the operator, which then fired CI. The run itself was fine - the only defect was the missing base branch. ## Why this is worth fixing in ward, not just "always auto-init the repo" Creating the repo with an initial commit before dispatch is the current workaround, but it is a manual step that is easy to skip (and was skipped here precisely because org-repo creation sat above the surface-session token tier, so it fell to a hand-create that omitted auto-init). ward already owns the land/reap decision, so it can handle the empty-repo case deterministically instead of relying on every dispatcher remembering to pre-seed `main`. ## Proposed behavior When the reaper (or the engineer at land time) finds **`origin/main` absent**: - Treat "no base branch" as an **establish-main** case, not a **salvage** case. If the run produced a clean, run-owned commit, push it **as** `main` (create the default branch from the run's work) rather than diverting to `ward-salvage/<id>`. That both lands the feature and initializes the repo in one step, and the push fires CI normally. - Only fall back to the salvage branch if the work is genuinely unlandable for a **real** reason (dirty/ambiguous tree, conflicting concurrent main that appeared, push rejected by branch protection) - i.e. the salvage path should key off an actual failure, not off the benign "the repo was empty" condition. - If ward would rather not have the reaper create default branches, the alternative is to **pre-flight** it: `ward agent` refuses to dispatch at a repo with no `origin/main` (or auto-seeds a `main` with an empty root commit before the container starts), so the container always has a base to land on. Either the reap-side or the dispatch-side fix removes the failure class. ## Acceptance - An engineer run dispatched at an empty repo lands its work on `main` and fires CI, with no manual salvage-branch promotion, OR is refused/pre-seeded at dispatch with a clear message. No silent salvage of a clean run just because the repo started empty. - The `origin/main absent` reap diagnostic no longer maps to `reason: push to main failed` for an otherwise-clean run. ## Refs - Live instance and the manual recovery: `coilyco-flight-deck/reddit-mcp#1` (reaper reopen comment carries the full diagnostics). - Related reaper/salvage doctrine: the container-agent reaper backstop (commit-loose -> integrate-onto-main -> push-or-salvage). Provenance: director surface session, 2026-07-04 - caught while dispatching the reddit-mcp source build.
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-599 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T08:17:18Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

The pre-flight judged this issue GO for an unattended run. Its justification:

pre-flight read (GO)

Context to front-load: I will read, in the fresh clone before my first edit — docs/agent.md and docs/agent-preflight.md (dispatch flow and where a pre-flight refusal/auto-seed would hook in), .agents/skills/tooling-ward-agent/SKILL.md (ref normalization and dispatch conventions), docs/container.md and docs/container-reap.md (the commit-loose → integrate-onto-main → push-or-salvage backstop this change rewires), the actual reaper/land implementation those docs describe (decision gate, diagnostics strings like origin/main absent and reason: push to main failed), its existing test layout so the new establish-main path gets covered the same way, and .ward/ward.yaml for the verbs to run tests/validators.

The issue is crisply specified with clear acceptance criteria, and it explicitly sanctions either the reap-side fix (push the clean run-owned commit as the new main) or the dispatch-side guard, so no open design question blocks me — the proposed-behavior section leans reap-side and I'd follow that lead, adding the dispatch-side pre-flight only if the code makes it cheap. The main risk is test fidelity: exercising "remote with no default branch" semantics unattended means building a fixture bare repo and verifying the decision gate keys off actual push failure rather than the benign empty-repo condition, and the reaper's gate logic may be more entangled with ancestry computation than the diagnostics suggest. That's an implementation-difficulty risk, not an ambiguity risk.

GO

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-599` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T08:17:18Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). The pre-flight judged this issue **GO** for an unattended run. Its justification: <details><summary>pre-flight read (GO)</summary> Context to front-load: I will read, in the fresh clone before my first edit — `docs/agent.md` and `docs/agent-preflight.md` (dispatch flow and where a pre-flight refusal/auto-seed would hook in), `.agents/skills/tooling-ward-agent/SKILL.md` (ref normalization and dispatch conventions), `docs/container.md` and `docs/container-reap.md` (the commit-loose → integrate-onto-main → push-or-salvage backstop this change rewires), the actual reaper/land implementation those docs describe (decision gate, diagnostics strings like `origin/main absent` and `reason: push to main failed`), its existing test layout so the new establish-main path gets covered the same way, and `.ward/ward.yaml` for the verbs to run tests/validators. The issue is crisply specified with clear acceptance criteria, and it explicitly sanctions either the reap-side fix (push the clean run-owned commit as the new `main`) or the dispatch-side guard, so no open design question blocks me — the proposed-behavior section leans reap-side and I'd follow that lead, adding the dispatch-side pre-flight only if the code makes it cheap. The main risk is test fidelity: exercising "remote with no default branch" semantics unattended means building a fixture bare repo and verifying the decision gate keys off actual push failure rather than the benign empty-repo condition, and the reaper's gate logic may be more entangled with ancestry computation than the diagnostics suggest. That's an implementation-difficulty risk, not an ambiguity risk. GO </details> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - reaper now establishes main from a clean empty-repo run instead of salvaging; landed on main as b21840e.

Went reap-side, as the issue leaned. The gate itself was a clean two-line swap - the !refExists origin/main branch now routes to a new reapEstablishMain instead of salvaging - but the interesting part was working out what "run-owned" means without an origin/main. The normal path's provenance/landed proof walks baseline_main..origin/main, which is structurally impossible when there's no baseline, so I leaned on the fact an empty repo has no stale history to mis-credit: the committed closes #N is sufficient ownership proof there. Junk-scan needed a base too, so it diffs against git's empty-tree object.

Two things fought back mildly. First, tracing the actual dispatch path: the Go bootstrap that writes provenance is gated behind an experimental flag that's never set, so the live path is still the shell entrypoint - I hardened writeRunProvenance anyway so an empty repo records an establish-main dispatch rather than fatal, for whenever that flag flips. Second, the comment-discipline hook (2-line blocks, 90-char cap) caught me over-commenting; trimmed and moved the detail to docs.

Fairly confident: the happy path and the salvage-guard (missing close-ref) are covered by fixtures using a real bare remote, so the push is exercised end to end, not mocked. Rough edge / possible follow-up: I did not also add the dispatch-side pre-flight refusal the issue floated as an alternative - the reap-side fix removes the failure class on its own, and doubling up felt like scope creep. If the reaper ever grows uneasy about creating default branches, that pre-flight is the place to add a second belt.

WARD-OUTCOME: done - reaper now establishes main from a clean empty-repo run instead of salvaging; landed on main as b21840e. Went reap-side, as the issue leaned. The gate itself was a clean two-line swap - the `!refExists origin/main` branch now routes to a new `reapEstablishMain` instead of salvaging - but the interesting part was working out what "run-owned" means without an `origin/main`. The normal path's provenance/`landed` proof walks `baseline_main..origin/main`, which is structurally impossible when there's no baseline, so I leaned on the fact an empty repo has *no stale history to mis-credit*: the committed `closes #N` is sufficient ownership proof there. Junk-scan needed a base too, so it diffs against git's empty-tree object. Two things fought back mildly. First, tracing the actual dispatch path: the Go bootstrap that writes provenance is gated behind an experimental flag that's never set, so the live path is still the shell entrypoint - I hardened `writeRunProvenance` anyway so an empty repo records an establish-main dispatch rather than fatal, for whenever that flag flips. Second, the comment-discipline hook (2-line blocks, 90-char cap) caught me over-commenting; trimmed and moved the detail to docs. Fairly confident: the happy path and the salvage-guard (missing close-ref) are covered by fixtures using a real bare remote, so the push is exercised end to end, not mocked. Rough edge / possible follow-up: I did *not* also add the dispatch-side pre-flight refusal the issue floated as an alternative - the reap-side fix removes the failure class on its own, and doubling up felt like scope creep. If the reaper ever grows uneasy about creating default branches, that pre-flight is the place to add a second belt.
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-599 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T17:27:25Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-599` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T17:27:25Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-599 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T17:27:25Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-599` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T17:27:25Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-599 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T17:27:25Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-599` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T17:27:25Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - already landed: the empty-repo establish-main fix is on main as b21840e (closes #599); this re-dispatch found nothing to do.

I came in fresh and read the reap subsystem expecting to build the establish-main path - and found it already there. A prior engineer run (b21840e, ~08:31 today) landed the exact reap-side fix this issue asks for: reapTargetTree routes the !refExists origin/main case to reapEstablishMain, which pushes HEAD as the new main when the run is clean and run-owned (its closes #N in committed history, junk scan against git's empty-tree, workflow lands on main), and only salvages on a genuine failure. Docs (container-reap.md), tests, and FEATURES.md were all updated in the same commit. The issue is already closed.

So this was a raced/duplicate dispatch, not new work. I verified rather than re-did: origin/main carries b21840e, local tree matches it, and the Reap|Establish tests pass green. I did not fabricate a duplicate commit or reopen anything - the done-condition was already satisfied.

Confidence high that #599 is resolved. Only rough edge is the one the prior run already flagged: the dispatch-side pre-flight refusal (the issue's floated alternative) was intentionally not added, since the reap-side fix removes the failure class on its own. No follow-up needed from me.

WARD-OUTCOME: done - already landed: the empty-repo establish-main fix is on `main` as b21840e (closes #599); this re-dispatch found nothing to do. I came in fresh and read the reap subsystem expecting to build the establish-main path - and found it already there. A prior engineer run (b21840e, ~08:31 today) landed the exact reap-side fix this issue asks for: `reapTargetTree` routes the `!refExists origin/main` case to `reapEstablishMain`, which pushes HEAD as the new `main` when the run is clean and run-owned (its `closes #N` in committed history, junk scan against git's empty-tree, workflow lands on main), and only salvages on a genuine failure. Docs (`container-reap.md`), tests, and `FEATURES.md` were all updated in the same commit. The issue is already closed. So this was a raced/duplicate dispatch, not new work. I verified rather than re-did: `origin/main` carries b21840e, local tree matches it, and the `Reap|Establish` tests pass green. I did not fabricate a duplicate commit or reopen anything - the done-condition was already satisfied. Confidence high that #599 is resolved. Only rough edge is the one the prior run already flagged: the dispatch-side pre-flight refusal (the issue's floated alternative) was intentionally not added, since the reap-side fix removes the failure class on its own. No follow-up needed from me.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#599
No description provided.