Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,39 @@ breaking changes may land in a minor release.

### Fixed

- Bind legacy migration dispatch and publication to the ledger bytes actually accepted
(DW-311, DW-316). Retire stale recovery authority when the ledger changes before
the true post-hook adapter-launch boundary, then publish a validated one-path,
clean-filter-normalized candidate through a prepared expected-old transaction on
the captured terminal direct branch. Resolve lost commit acknowledgements by
deterministic replay, and reconcile the target index against bounded stable checkout
observations while preserving unrelated stages. An absent committed target is accepted
only when the baseline commit proves the ledger was never tracked: a ledger a rival
commit deleted after the baseline was taken refuses publication instead of being
silently re-added on top of that commit, the committed twin of the staged deletion
the publisher already refuses; a ledger the transition itself published that a later
commit removed refuses its replay the same way. The candidate carries the live ledger's
own bytes, read once they are proven to decode to the accepted text, so the committed
blob is what `git add` of the validated file stages under any line-ending configuration
and a CRLF checkout is not left dirty beside an LF commit; every later validation
re-reads those bytes and holds the target's size, mtime and ctime with its inode, so a
rewrite in place — rival bytes, or the same text under other line endings — is refused
before the transaction commits rather than noticed after it. The baseline's identity
is the blob its commit holds, bound to the baseline text under the ledger readers'
universal-newline decoding, rather than the LF blob re-encoding that text names: a
tracked legacy ledger Git preserves with CRLF bytes (`core.autocrlf=false`, the
shape every Windows-written ledger takes) was refused as rival content and left the
migration in COMMITTING for good.

- Refuse no-descriptor attempt-owned spec restoration before staging or lifecycle
normalization, preserving the existing target bytes for manual recovery (DW-310).

- Fail closed after no-descriptor attempt-owned spec publication and pause for
manual adoption instead of trusting a path-based readback (DW-309).

- Refuse attempt-owned spec recovery when final prepublication validation observes an
existing target was edited in place, preserving the competing bytes (DW-308).

- Restore the accepted commit chain and index when the post-squash HEAD identity
probe fails, retaining the probe fault if rollback also fails (DW-305).

Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6510,6 +6510,7 @@ def _run_session(
label: str | None = None,
spec_snapshot: SpecSnapshot | None = None,
preserve_dispatched_spec_snapshot: bool = False,
prelaunch_validator: Callable[[], None] | None = None,
) -> SessionResult:
# ``label`` names a non-standard session (a plugin-provided workflow) so
# its task_id stays distinct from the role's own dev/review attempts.
Expand Down Expand Up @@ -6575,6 +6576,12 @@ def _run_session(
if sctx is not None:
veto = sctx.resolved_veto()
if veto is not None:
# A veto prevents adapter launch but does not undo executable
# hook side effects. Callers whose durable launch authority is
# bound to mutable input must validate that input before the
# early return just as they do on the normal launch path.
if prelaunch_validator is not None:
prelaunch_validator()
self.journal.append(
"plugin-veto",
stage=sctx.stage,
Expand Down Expand Up @@ -6658,6 +6665,13 @@ def _run_session(
/ f"{self._dev_skill(role)}-result-{task_id}.md"
)
prompt += WORKFLOW_COMPLETION_CONTRACT.format(marker_path=marker_path)
# Optional transaction boundary for callers whose durable launch
# authority is tied to mutable workspace input. It deliberately runs
# after every executable session hook and every prompt/snapshot repair,
# but before either the session-start record or adapter launch. The
# default keeps all existing callers byte-for-byte inert.
if prelaunch_validator is not None:
prelaunch_validator()
Comment on lines +6673 to +6674

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate migration input at the actual adapter launch

When another process edits the ledger after this callback returns but before adapter.run(spec) begins, the migration session still launches using recovery records built from the old text; SessionSpec construction and the session-start journal write remain between this check and the launch. The child can then overwrite the concurrent edit, after which validation blesses its output against the stale manifest and the bound publisher sees only the accepted rewrite, so the promised fail-closed behavior is bypassed. Move the validation to the adapter-launch boundary or bind the child's first ledger write with equivalent compare-and-set authority.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not taking this one — the window it names cannot be closed by moving the check, and it is already the smallest window on the path.

Between prelaunch_validator() and adapter.run(spec) there are exactly three things: SessionSpec(...) construction (pure), journal.set_active_log, and the session-start journal append — both writes land in the run dir, never the ledger. No executable hook runs there, which is what this validator exists to bound (DW-316's contract in FEATURES.md is "after executable pre-session hooks at the actual adapter-launch boundary", and every hook stage sits above it). Beyond adapter.run the child CLI takes seconds to start and minutes to rewrite the ledger; a rival write anywhere in that span produces the identical outcome the finding describes, and that span is what validate_migration against the durable manifest and the bound publisher's baseline/blob checks are for. A write in the microseconds between the validator and the launch is indistinguishable, from the child's side, from one landing during its startup — it is the same race, and the only guard against it is the post-session one.

Moving the call after the session-start record would also break the #157 pairing invariant: _retire_migration_dispatch_authority raises, and a raise between session-start and the try around adapter.run leaves the journal showing a session that never ended. Binding "the child's first ledger write" with compare-and-set authority is not available either — the child is a coding-CLI session that owns the file for its whole run; the orchestrator has no seam in it.

spec = SessionSpec(
task_id=task_id,
role=role,
Expand Down
Loading
Loading