Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ breaking changes may land in a minor release.

### Fixed

- **An abandoned patch-restore no longer smuggles its files into the corrected story's commit.**
Re-arming a story whose previous re-drive had already applied a restore patch snapshotted that
patch's new (untracked) files as _pre-existing_, so every later rollback preserved them and
`finalize_commit`'s `add -A` swept the abandoned attempt into the corrected commit. The re-arm now
parses the old latch (`verify.patch_new_files`) and subtracts its creations from the refreshed
baseline snapshot — the re-drive's own reset then removes them. Best-effort: a missing or
unreadable patch degrades to the old behavior instead of failing the resolve. Commits the
escalated attempt left below the advanced baseline can't be reverted mechanically (the resolve
session's own commits share that range), so they are journaled and echoed to stderr for the human
to classify. New journal events: `stale-restore-excluded` / `-unparseable` / `-commits`.
(closes #90)
- **Baseline-era untracked residue no longer vacuously satisfies the proof-of-work gate.**
`has_changes_since` counted every untracked file. After an intent-gap halt the saved patch is
untracked residue under the artifact dirs every reset deliberately protects, so a from-scratch
Expand Down
32 changes: 32 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,36 @@ def _resolve_restore_patch(
return str(patch), None


def _echo_stale_restore(run_dir: Path, seen_entries: int) -> None:
"""Surface the `stale-restore-*` events a just-completed re-arm journaled about
the restore attempt it abandoned (runs._stale_restore_residue). The commits
variant is the one the human must act on — nothing else will."""
for entry in Journal(run_dir).entries()[seen_entries:]:
kind = entry.get("kind", "")
if kind == "stale-restore-excluded":
files = ", ".join(entry.get("files", []))
print(
f"note: excluded the abandoned restore's new files from the "
f"re-drive baseline: {files}",
file=sys.stderr,
)
elif kind == "stale-restore-unparseable":
print(
f"warning: could not read the abandoned restore patch "
f"({entry.get('patch', '?')}) — its new files may be swept into the "
"next commit; check `git status` before resuming",
file=sys.stderr,
)
elif kind == "stale-restore-commits":
n = len(entry.get("commits", []))
print(
f"warning: {n} commit(s) sit below the re-drive's new baseline "
f"({entry.get('old_baseline', '?')[:12]}..) — if any came from the "
"abandoned attempt rather than your resolve, revert them now",
file=sys.stderr,
)


def cmd_resolve(args: argparse.Namespace) -> int:
from .model import PAUSE_ESCALATION, Phase

Expand Down Expand Up @@ -993,11 +1023,13 @@ def cmd_resolve(args: argparse.Namespace) -> int:
if args.resume is None and not _confirm(f"re-arm {story_key} and resume run {args.run_id}?"):
print("cancelled — run is still paused at the escalation")
return 0
seen_entries = len(Journal(run_dir).entries())
try:
runs.rearm_escalation(run_dir, story_key, restore_patch=restore_patch)
except runs.RearmError as e:
print(f"error: {e}", file=sys.stderr)
return 1
_echo_stale_restore(run_dir, seen_entries)
print(
f"re-armed {story_key}"
+ (" (restoring the attempted change for review)" if restore_patch else "")
Expand Down
92 changes: 91 additions & 1 deletion src/bmad_loop/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,10 @@ def rearm_escalation(
)

journal = Journal(run_dir)
# Read before the unconditional overwrite below: they describe the restore
# attempt this re-arm is abandoning, and the residue block needs both.
old_latch = task.restore_patch
old_baseline = task.baseline_commit
# deliberate reset, not a normal state-machine transition (mirrors
# engine._finish_inflight): a clean re-attempt against the corrected spec.
task.phase = Phase.PENDING
Expand Down Expand Up @@ -650,6 +654,21 @@ def rearm_escalation(
f"(it must be readable UTF-8), then re-run resolve"
) from e

# A previous restore latch is being replaced (or re-latched onto the same
# patch): the abandoned attempt applied that patch, so its NEW files sit
# untracked in the tree right now. The refresh below would capture them as
# "pre-existing" — after which every rollback preserves them and
# finalize_commit's `add -A` sweeps the abandoned attempt into the corrected
# story's commit. Subtract them instead (issue #90).
#
# Runs after the spec block for the same reason the refresh does (a cleared
# sentinel must not be snapshotted), and before it because it feeds it.
# Nothing is deleted here: the re-drive's reset (verify.safe_rollback) removes
# whatever the refreshed snapshot no longer blesses, at the right moment.
stale_residue = _stale_restore_residue(
Path(state.project), journal, key, old_latch, old_baseline
)

# Advance the attempt baseline to the project's current HEAD and refresh the
# untracked snapshot: whatever the human-driven resolve session left on the
# branch (a committed fixture, a corrected ledger, ...) is authorized input
Expand All @@ -668,7 +687,7 @@ def rearm_escalation(
try:
repo = Path(state.project)
head = verify.rev_parse_head(repo)
untracked = sorted(verify.untracked_files(repo))
untracked = sorted(verify.untracked_files(repo) - stale_residue)
task.baseline_commit = head
task.baseline_untracked = untracked
except Exception: # noqa: BLE001 # nosec B110 - best-effort git read, must not fail re-arm
Expand Down Expand Up @@ -703,6 +722,77 @@ def rearm_escalation(
return key


def _stale_restore_residue(
repo: Path,
journal: Journal,
story_key: str,
old_latch: str | None,
old_baseline: str | None,
) -> set[str]:
"""The untracked files an abandoned patch-restore attempt left in the tree —
to be subtracted from the re-arm's refreshed `baseline_untracked` (issue #90).

Empty when no restore was latched. Deliberately *not* a `git apply -R`: the
re-drive's own reset already reverts the patch's tracked hunks, an `apply -R`
fails outright on any drift the resolve session introduced, and it misbehaves
on the committed variant below. Only the patch's new files are durable
contamination, and naming them is enough — `verify.safe_rollback` deletes
whatever the refreshed snapshot stops blessing.

Also journals (warn-only) the commits sitting between the OLD baseline and the
new one: a commit the escalated re-drive session made now becomes the next
re-drive's permanent starting point, and no reset revisits it. It is not
mechanically reversible — the resolve session's own blessed commits live in the
same range and reverting those would claw back the human's resolution — so the
human is the classifier. `bmad-loop resolve` echoes these to stderr.

Best-effort throughout: a deleted or unreadable patch, a non-repo project, a
bad old baseline — none may wedge a resolve. Every failure degrades to the
pre-#90 behavior and says so in the journal.
"""
if not old_latch:
return set()
patch_path = Path(old_latch)
if not patch_path.is_absolute():
patch_path = repo / patch_path

residue: set[str] = set()
try:
residue = verify.patch_new_files(patch_path)
except (OSError, UnicodeDecodeError) as e:
# degrade to the pre-#90 snapshot rather than wedge the resolve
journal.append(
"stale-restore-unparseable",
story_key=story_key,
patch=str(patch_path),
error=f"{e.__class__.__name__}: {e}",
)
else:
if residue:
journal.append(
"stale-restore-excluded",
story_key=story_key,
patch=str(patch_path),
files=sorted(residue),
)

# Independent of the parse above — an unreadable patch must not also cost the
# human the only notice they get about the committed variant.
if old_baseline:
try:
shas = verify.commits_above(repo, old_baseline)
except Exception: # noqa: BLE001 # nosec B110 - warn-only, must not fail re-arm
shas = []
if shas:
journal.append(
"stale-restore-commits",
story_key=story_key,
old_baseline=old_baseline,
commits=shas,
)
return residue


def _sentinel_condition(spec_path: Path, story_key: str) -> str | None:
"""The blocking condition (``unresolved`` / ``ambiguous``) iff ``spec_path`` is
a fixed-slug pre-planning-halt sentinel for ``story_key``, else None."""
Expand Down
52 changes: 52 additions & 0 deletions src/bmad_loop/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
GIT_TIMEOUT_S = 120
COMMAND_TIMEOUT_S = 30 * 60

# How git's own diff format names the absent side of a creation/deletion. A
# protocol token git emits verbatim on every platform, Windows included — never
# opened, never joined onto. Only `patch_new_files` reads it.
_DIFF_ABSENT = "/dev/null" # portability: git diff-format token, not a real path

# result.json `workflow` value for the dev pass. A machine contract: the
# orchestrator forges this value in `devcontract` when synthesizing the dev
# result from the spec the bmad-dev-auto session leaves on disk; a mismatch
Expand Down Expand Up @@ -1550,6 +1555,53 @@ def apply_patch(repo: Path, patch_path: Path) -> None:
raise GitError(f"git apply {patch_path} failed: {out}")


def patch_new_files(patch_path: Path) -> set[str]:
"""Repo-relative posix paths the saved patch *creates* — the untracked residue
an `apply_patch` leaves behind (see `runs.rearm_escalation`).

Text-parse, not `git apply --numstat`: the caller runs after the tree has moved
on, so the patch may no longer apply, and a creation list must still come back.
Within each `diff --git` block, an old-side `---` header naming `_DIFF_ABSENT`
marks a creation, and the `+++ <prefix>/<path>` after it names the file. The
prefix is stripped by mirroring what `apply_patch`'s plain `git apply` (default
-p1) did when it laid the residue down: drop the first path component whatever
it is — `b/` standard, `w/`/`i/`/`c/` under diff.mnemonicPrefix, `2/` from
--no-index. A target -p1 cannot strip (no `/`, e.g. --no-prefix output) is
skipped: that apply failed outright, so no residue exists. Deletions (the
absent token on the *new* side) are never returned — the caller feeds this to an
*exclusion* set, and excluding a path the human later re-created would make the
next rollback delete their file. For the same reason every ambiguous entry is
skipped rather than guessed: quoted paths (`+++ "b/wéird"`, core.quotePath),
renames, and non-`git diff` unified diffs with no `diff --git` header yield fewer
results, never wrong ones. Under-reporting degrades to the pre-#90 behavior;
over-reporting deletes user data.

Raises OSError / UnicodeDecodeError when the patch cannot be read; the caller
decides (rearm treats it as best-effort and journals `stale-restore-unparseable`).
"""
new_files: set[str] = set()
in_hunk = False # past the first `@@`, a `--- x` line is content, not a header
creating = False
for line in patch_path.read_text(encoding="utf-8").splitlines():
if line.startswith("diff --git "):
in_hunk = creating = False
elif line.startswith("@@"):
in_hunk = True
elif in_hunk:
continue
elif line.startswith("--- "):
creating = line[4:].strip() == _DIFF_ABSENT
elif line.startswith("+++ ") and creating:
creating = False
target = line[4:].split("\t", 1)[0].strip()
if target == _DIFF_ABSENT or target.startswith('"') or "/" not in target:
continue # delete-then-create pair, quoted path, or un-strippable target
rel = target.split("/", 1)[1] # mirror `git apply`'s default -p1
if rel:
new_files.add(rel)
return new_files


def commit_paths(repo: Path, message: str, paths: list[Path]) -> str | None:
"""Commit exactly `paths` (and nothing else), leaving any unrelated working
or staged changes untouched. Unlike commit_story's `add -A`, this is safe to
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,36 @@ def test_resolve_no_interactive_rearms_and_resumes(tmp_path, monkeypatch, capsys
assert "ready-for-dev" in spec.read_text()


def test_resolve_echoes_this_rearms_stale_restore_events(tmp_path, monkeypatch, capsys):
"""#90's journal entries reach the operator. The commits variant is warn-only —
stderr is the only place it ever surfaces. Entries from *earlier* re-arms are
already-acted-on history and must not be replayed."""
from bmad_loop import runs
from bmad_loop.journal import Journal

run_dir = _escalated_run(tmp_path, "r1")
Journal(run_dir).append("stale-restore-excluded", story_key="s1", files=["FROM-LAST-TIME.txt"])

def fake_rearm(rd, key, *, restore_patch=None):
journal = Journal(rd)
journal.append("stale-restore-excluded", story_key=key, patch="a.patch", files=["new.txt"])
journal.append("stale-restore-unparseable", story_key=key, patch="b.patch", error="OSErr")
journal.append("stale-restore-commits", story_key=key, old_baseline="f" * 40, commits=["c"])
return key

monkeypatch.setattr(runs, "rearm_escalation", fake_rearm)
monkeypatch.setattr(cli, "_resume_paused_run", lambda proj, rd: 0)
assert (
cli.main(["resolve", "--project", str(tmp_path), "r1", "--no-interactive", "--resume"]) == 0
)

err = capsys.readouterr().err
assert "excluded the abandoned restore's new files from the re-drive baseline: new.txt" in err
assert "could not read the abandoned restore patch (b.patch)" in err
assert "1 commit(s) sit below the re-drive's new baseline (ffffffffffff..)" in err
assert "FROM-LAST-TIME.txt" not in err


def test_resolve_interactive_runs_session_then_rearms(tmp_path, monkeypatch):
from bmad_loop import resolve
from bmad_loop.journal import load_state
Expand Down
9 changes: 6 additions & 3 deletions tests/test_portability_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@
# *are* the sanctioned spot (their module docstrings say so).
TMUX_BACKENDS = {"adapters/tmux_base.py", "adapters/tmux_backend.py"}

# Platform-guarded files that may name a bare POSIX path, each on a line carrying
# a `# portability:` ack (and guarded by a sys.platform branch). process_host.py's
# Linux identity reader walks `/proc/<pid>/stat`.
# Files that may name a bare POSIX path, each on a line carrying a `# portability:`
# ack. process_host.py's Linux identity reader walks `/proc/<pid>/stat` behind a
# sys.platform branch; the Unity teardown scripts are POSIX-only. verify.py is the
# one non-platform case: git's *diff format* spells an absent file `/dev/null` on
# every platform, so `patch_new_files` compares against it as a protocol token.
PATH_ALLOW = {
"data/plugins/unity/unity_cleanup.py",
"data/plugins/unity/unity_teardown.py",
"process_host.py",
"verify.py",
}

# The two detach helpers that legitimately request POSIX `start_new_session`.
Expand Down
Loading
Loading