Skip to content

fix(install): restore a discarded conflicted lockfile when the run fails - #3043

Closed
Lachlan Heywood (lachieh) wants to merge 5 commits into
microsoft:mainfrom
lachieh:lockfile-conflict-transaction
Closed

Lachlan Heywood (lachieh) wants to merge 5 commits into
microsoft:mainfrom
lachieh:lockfile-conflict-transaction

Conversation

@lachieh

@lachieh Lachlan Heywood (lachieh) commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Description

Follow-up to #3028, stacked on its branch. The branch lives on my fork, so this PR targets main and shows #3028's two commits until it merges; the change here is the last two commits (2ede6371, f62b85a5). It addresses the APM Review Panel's blocking item and the top follow-ups from the panel comment.

1. Failed install after discard (blocking, Test Coverage Expert) and transactional discard (Python Architect #2). These are the same gap, so one fix closes both. InstallTransaction.discard_conflicted_lockfile(path) replaces the module-level helper: it snapshots the conflicted bytes before unlinking, and rollback() restores the file unless the attempt already wrote a new lockfile. apm lock now runs under its own InstallTransaction (previously it relied on the pipeline's compatibility transaction, which never saw the discard), so the same rule applies there.

Coverage: tests/unit/install/test_install_transaction.py (discard then fail() restores byte-exact; rollback keeps a lockfile written after the discard; commit does not restore; valid, corrupt, undecodable, and missing files are never discarded) and tests/integration/test_install_conflicted_lockfile_e2e.py::test_failed_run_after_discard_restores_the_conflicted_lockfile for both apm install and apm lock against a manifest whose dependency cannot resolve. Verified manually as well: both commands print apm.lock.yaml restored to its previous state. and leave the conflicted bytes intact; a successful run still regenerates.

2. One conflict policy (Python Architect #5). Detection stays in LockFile.read. The only durable outcome that differs from "raise" is the discard, and it now has one owner (the transaction) with two callers (apm install full mode, apm lock). Frozen, partial, dry-run, and MCP paths do not compute a policy; they read and let LockfileConflictError surface. I did not add a policy object over that: it would wrap one boolean.

3. Docs precision (Doc Writer #3). install.md now says a full apm install (no package arguments, no --only) discards; lockfile-spec.md, lock.md, and install-failures.md state that a failed run restores the file. Frozen, partial, and dry-run wording is unchanged from #3028, which already said they never rewrite.

4. commands.md "hermes/target churn" (Doc Writer #4). Not applicable. git diff main...install-on-invalid-lockfile --word-diff -- packages/apm-guide/.apm/skills/apm-usage/commands.md shows the only change is missing -> missing, contains git merge conflict markers, in the --frozen phrase, applied to each of the five pre-existing duplicate apm install rows. The hermes/target differences between those rows predate both PRs.

5. Copilot review on this PR. The discard/restore decision is now a registered architecture owner (conflicted-lockfile-discard in .apm/architecture/owners/install-deployment.json) with the install-deployment-conflicted-lockfile-discard guard: the linter check requires the transaction to define discard and restore and call restore from rollback(), requires both command callers to go through the transaction, and flags any other has_conflict_markers use or discard_conflicted_lockfile definition under src/. The mutation matrix proves the guard fires when rollback stops restoring. Also from that review: apm lock migrates a legacy apm.lock before the discard probe and runs its transaction as a context manager so an interrupt after the discard still restores the file (both covered by e2e tests), and the MCP add path names only LockfileConflictError at default verbosity so other format errors keep their redacted handling. The service.py/dry_run.py legacy-path notes describe behaviour that predates #2979 (enforce_frozen and the dry-run preview never resolved apm.lock), so they are left for a separate issue.

Out of scope, per the panel's own dissent: --force (not a regenerate switch), CI autodetection, dry-run exit codes.

Issue and approved scope

Issue: #2979 (accepted; implementation PR #3028)

Human scope-approval comment: maintainer request on #3028 to fix the panel's blocker and, optionally, the top-5 follow-ups.

Does this PR complete the issue? #3028 completes #2979; this PR closes the panel follow-ups on it. It references the issue without closing it.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

tests/unit + tests/test_console.py: 22534 passed. tests/integration/test_install_*.py, test_lock*.py, test_mcp_*.py, test_architecture_owner_rule_mutations.py, test_architecture_linter_entrypoint.py, tests/unit/scripts/test_architecture_runner.py, and tests/quality: pass, except test_matrix_covers_every_registry_guard_exactly_once, which fails identically on main (two unrelated guards missing from the matrix). bash scripts/lint-architecture-boundaries.sh exits 0. ruff check, ruff format --check clean; mypy on the touched modules adds no errors.

Spec conformance (OpenAPM v0.1)

  • N/A -- this PR does not change OpenAPM-observable behaviour.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved moderate findings remain in legacy-lockfile handling, interruption rollback, and MCP error handling.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Low severity

Open (1)
What changed in this PR

This PR makes conflicted lockfile removal transactional, restoring the original file when full installation or apm lock fails.

Changes:

  • Adds conflict detection and transactional discard/restore behavior.
  • Preserves fail-closed behavior for frozen, partial, and dry-run modes.
  • Adds tests, documentation, usage guidance, and changelog updates.
File Summary Final review note
tests/​unit/​install/​test_install_transaction.py Tests snapshot, discard, rollback, and restoration behavior. —
tests/​unit/​install/​test_frozen.py Tests frozen-mode conflict handling. —
tests/​unit/​deps/​test_lockfile_conflict_markers.py Tests conflict-marker detection. —
tests/​integration/​test_install_conflicted_lockfile_e2e.py Covers install and lock recovery flows. —
src/​apm_cli/​install/​transaction.py Owns discarded lockfile restoration. Nit (2 votes): Add an owner record and registry-backed architecture guard covering the transaction and its callers.
src/​apm_cli/​install/​service.py Improves frozen-mode diagnostics. Moderate (1 vote): Resolve legacy lockfile paths for frozen reads.
src/​apm_cli/​install/​presentation/​dry_run.py Reports conflicts without mutation. Moderate (1 vote): Resolve legacy lockfile paths during dry runs.
src/​apm_cli/​install/​mcp/​command.py Handles lockfile format failures. Moderate (1 vote): Catch LockfileConflictError specifically so other format errors retain redacted actionable handling.
src/​apm_cli/​install/​errors.py Refines frozen recovery guidance. —
src/​apm_cli/​deps/​lockfile.py Detects conflict markers and raises the conflict error. —
src/​apm_cli/​commands/​lock.py Adds transactional lock generation. Moderate (1 vote): Handle supported legacy apm.lock files before discard checks. Moderate (1 vote): Ensure BaseException paths roll back after discard.
src/​apm_cli/​commands/​install.py Applies discard policy to full installs. —
packages/​apm-guide/​.apm/​skills/​apm-usage/​troubleshooting.md Adds recovery guidance. —
packages/​apm-guide/​.apm/​skills/​apm-usage/​commands.md Updates frozen-mode guidance. —
docs/​src/​content/​docs/​troubleshooting/​install-failures.md Documents conflict recovery and rollback. —
docs/​src/​content/​docs/​reference/​lockfile-spec.md Defines conflict semantics. —
docs/​src/​content/​docs/​reference/​cli/​lock.md Documents lock recovery. Nit (1 vote): Qualify restoration as applying before a replacement lockfile is written.
docs/​src/​content/​docs/​reference/​cli/​install.md Documents frozen and full-install behavior. —
CHANGELOG.md Records the fix. —

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/apm_cli/install/transaction.py
… under --frozen

LockFile.read raises LockfileConflictError, a LockfileFormatError, when
apm.lock.yaml contains git merge conflict markers, so every command names
the file and a next action instead of printing a YAML scanner error.

A full apm install and apm lock warn, discard the conflicted file, and
resolve from apm.yml. Partial installs (positional packages, --only,
--mcp) and apm install --frozen fail closed with the named error, and the
frozen tip no longer points at apm outdated when the lockfile cannot be
read.

Fixes microsoft#2979
…tions

Keep the conflict-marker read inside LockFile.read's normalising try so a
non-UTF-8 lockfile still fails through LockfileFormatError, and leave such
a file in place instead of probing it for discard. The generic --frozen
unreadable-lockfile error now names the repair step. apm lock export
resolves a legacy apm.lock like the other read-only consumers. Narrow the
docs claim to commands that require the lockfile, update the apm-guide
skill resources, mark the new test modules as component tests, and cite
the PR number in the changelog.
Other lockfile format errors keep the redacted, verbose-only handling
that path had before microsoft#3028 widened the except clause.
InstallTransaction now owns the conflicted-lockfile discard: it snapshots
the bytes before unlinking and rollback puts the file back unless the
attempt already wrote a new lockfile. apm lock runs under its own
transaction so the same rule applies there. The module-level
discard_conflicted_lockfile helper is removed.

Follow-up to microsoft#3028 from the APM Review Panel.
…en apm lock

Record InstallTransaction.discard_conflicted_lockfile as the canonical
owner in .apm/architecture/owners/install-deployment.json with the
install-deployment-conflicted-lockfile-discard guard, its linter check,
and a mutation-matrix case proving rollback must restore the file.

apm lock now migrates a legacy apm.lock before the discard probe and runs
the transaction as a context manager so an interrupt after the discard
still restores the conflicted lockfile. The MCP add path names only the
conflict error at default verbosity; other lockfile format errors keep
their redacted handling.
@lachieh

Copy link
Copy Markdown
Contributor Author

Closing temporarily. GitHub cannot base a cross-fork PR on a fork branch and stacked PRs are not enabled on this repository, so this PR's diff includes #3028's commits and Copilot keeps re-reviewing already-approved changes. The branch is rebased onto the current install-on-invalid-lockfile (itself rebased onto main after 0.31.0) and holds only the two follow-up commits (06e28e15, 0f173181). I will reopen it as soon as #3028 merges, when its diff collapses to those two commits. The MCP-add narrowing Copilot flagged here moved to #3028 (4456bb6b) since it changes that PR's code.

@lachieh

Copy link
Copy Markdown
Contributor Author

Reopened: closing did not change what reviewers see and only hid the panel's blocker fix. Until #3028 merges, this PR's diff includes its commits; the follow-up itself is the last two (06e28e15, 0f173181). Copilot findings on the earlier commits belong to #3028 and are handled there.

@lachieh

Copy link
Copy Markdown
Contributor Author

Superseded by #3028. Both commits from this branch (41a919a9 transactional discard/restore with unlink-then-fail coverage, 7943869b owner registration plus apm lock hardening) are folded into #3028, so the panel's blocking item and Copilot's owner-guard request are resolved in the PR that is actually queued.

Splitting this out did not work: GitHub cannot base a cross-fork PR on a fork branch and stacked PRs are not enabled on this repository, so this PR's diff always carried #3028's commits and reviewers re-reviewed already-approved code. Nothing here is dropped.

@lachieh
Lachlan Heywood (lachieh) deleted the lockfile-conflict-transaction branch September 22, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants