-
Notifications
You must be signed in to change notification settings - Fork 58
fix(resolve): session root context and park assertion provenance (DW-14 … DW-51) #759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
67a3e2d
84fa8ca
26b5f09
fc65024
fa0b934
c6f57ac
60ab6fe
fb521a1
0182d06
21c1d9b
35fd2ff
9af10d9
b9207ca
7ba1bda
89bf2e3
520118b
b9e1e5a
024ceee
0c7a75c
4fe4efd
a42f1c9
871b117
f53c21e
285821b
5351da9
b1b27d0
63f2bbf
fe2783c
95d30e9
f1baf7f
64bca0d
0bdc4d6
b746d5a
44bfcfd
8ecc8ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,7 @@ | |
| import time | ||
| from collections.abc import Callable | ||
| from pathlib import Path | ||
| from typing import TYPE_CHECKING | ||
| from typing import TYPE_CHECKING, Protocol, cast | ||
|
|
||
| from .. import devcontract, gates, runs | ||
| from ..bmadconfig import ProjectPaths | ||
|
|
@@ -1269,6 +1269,12 @@ def read_usage(self, result: SessionResult) -> TokenUsage | None: | |
| time.sleep(RESULT_POLL_S) | ||
|
|
||
|
|
||
| class _SessionStarter(Protocol): | ||
| """Next concrete adapter in the dev mixin's cooperative MRO.""" | ||
|
|
||
| def start_session(self, spec: SessionSpec) -> SessionHandle: ... | ||
|
|
||
|
|
||
| class _DevSynthesisMixin(_ResultFileMixin): | ||
| """Result synthesis for the generic ``bmad-build-auto`` skill, shared by | ||
| every transport that drives it (tmux today; see GenericDevAdapter for the | ||
|
|
@@ -1327,6 +1333,93 @@ def _configure_dev_knobs(self) -> None: | |
| # apply — this budget is not a counter and touches no stall counters). | ||
| self._contract_nudge_sent: set[str] = set() | ||
| self._contract_nudge_enabled = self.policy.limits.dev_contract_nudge | ||
| # Marker identities present immediately before each real session launch. | ||
| # The adapter, not whole-file mtime, owns this attempt-relative evidence: | ||
| # touching another part of a parked spec must not make its retained marker | ||
| # look session-authored. A task-level None means directory enumeration was | ||
| # incomplete; a path-level None means that one launch file was unreadable. | ||
| # Both fail closed at the affected scope without letting an unrelated bad | ||
| # Markdown file suppress a newly created, readable story spec. | ||
| self._launch_auto_run_results: dict[str, dict[str, tuple[int, str] | None] | None] = {} | ||
|
|
||
| @staticmethod | ||
| def _marker_path_key(path: Path) -> str: | ||
| # `(OSError, RuntimeError)`, like every other `resolve()` guard in this | ||
| # package: on the 3.11 support floor a symlink LOOP raises RuntimeError, | ||
| # not an OSError (3.13 resolves it silently), and a bare `except OSError` | ||
| # let one looped `*.md` under an artifact dir abort the launch capture — | ||
| # and with it every unpinned dev session — before the transport started. | ||
| try: | ||
| return str(path.resolve()) | ||
| except (OSError, RuntimeError): | ||
| return str(path.absolute()) | ||
|
|
||
| def _capture_launch_auto_run_results(self, spec: SessionSpec) -> None: | ||
| """Snapshot real result markers before the child can write its spec.""" | ||
| paths: list[Path] = [] | ||
| complete = True | ||
| if spec.expected_spec: | ||
| expected = Path(spec.expected_spec) | ||
| paths = [expected if expected.is_absolute() else Path(spec.cwd) / expected] | ||
| else: | ||
| for artifacts in self._artifact_dirs(spec.cwd): | ||
| try: | ||
| paths.extend(artifacts.glob("*.md")) | ||
|
Comment on lines
+1364
to
+1367
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For every session without Useful? React with 👍 / 👎. |
||
| except OSError: | ||
| complete = False | ||
|
|
||
| captured: dict[str, tuple[int, str] | None] = {} | ||
| for path in paths: | ||
| key = self._marker_path_key(path) | ||
| try: | ||
| text = path.read_text(encoding="utf-8") | ||
| except FileNotFoundError: | ||
| continue | ||
| except (OSError, UnicodeDecodeError): | ||
| captured[key] = None | ||
| continue | ||
| fingerprint = devcontract.auto_run_result_fingerprint(text) | ||
| if fingerprint[0]: | ||
| captured[key] = fingerprint | ||
| self._launch_auto_run_results[spec.task_id] = captured if complete else None | ||
|
|
||
| def start_session(self, spec: SessionSpec) -> SessionHandle: | ||
| self._capture_launch_auto_run_results(spec) | ||
| # The mixin is shared by two unrelated concrete transports. Keep the | ||
| # cooperative MRO dispatch rather than naming either host explicitly; | ||
| # the protocol gives Pyright the host contract without adding a runtime | ||
| # base that could alter method resolution. | ||
| return cast(_SessionStarter, super()).start_session(spec) | ||
|
|
||
| def _park_marker_session_authored(self, spec_path: Path, spec: SessionSpec) -> bool: | ||
| """Whether the live marker differs from this session's launch marker.""" | ||
| if spec.task_id not in self._launch_auto_run_results: | ||
| # Production always enters through start_session. A direct diagnostic | ||
| # read-back has no attempt-relative evidence and therefore fails closed. | ||
| return False | ||
| captured = self._launch_auto_run_results[spec.task_id] | ||
| if captured is None: | ||
| return False | ||
| try: | ||
| current = devcontract.auto_run_result_fingerprint(spec_path.read_text(encoding="utf-8")) | ||
| except (OSError, UnicodeDecodeError): | ||
| return False | ||
| key = self._marker_path_key(spec_path) | ||
| if key in captured: | ||
| launch = captured[key] | ||
| if launch is None: | ||
| return False | ||
| # Appending another marker is authorship even when its text repeats; | ||
| # an in-place rewrite is authorship when the final section changes. | ||
| # Deleting older sections while retaining the same final marker is not. | ||
| return current[0] > launch[0] or (current[0] == launch[0] and current[1] != launch[1]) | ||
|
|
||
| # A marker moved or copied from another launch path is inherited evidence, | ||
| # not a marker authored by this attempt. A genuinely new marker whose text | ||
| # happens to collide also fails closed; byte identity cannot prove authorship. | ||
| if current in (fingerprint for fingerprint in captured.values() if fingerprint): | ||
| return False | ||
| return current[0] > 0 | ||
|
|
||
| def _probe_alive(self, handle: SessionHandle) -> bool | None: | ||
| """Liveness of the session's native surface (tmux window, server | ||
|
|
@@ -1418,10 +1511,13 @@ def _known_spec_synth_result( | |
| observation and the M1 launch-snapshot gate all still apply — scoped to the | ||
| one legitimate path instead of a shared directory. | ||
|
|
||
| No launch-snapshot gate is needed on the marker branch itself: the | ||
| No whole-file launch-snapshot gate is needed on the marker branch itself: the | ||
| pre-review-launch strip (`Engine._reset_spec_for_review`) REMOVES the | ||
| marker, so a spec carrying one again has necessarily changed bytes since the | ||
| snapshot and the gate would be a no-op (`_snapshot_verdict` → NEUTRAL). | ||
| Marker-level launch capture still runs for every real session: it prevents | ||
| an unrelated post-launch touch from lending a retained park marker to the | ||
| new attempt. | ||
|
|
||
| Note this deliberately does NOT fall back to the scan when the expected spec | ||
| yields nothing: a session that did not write the spec it owed produced no | ||
|
|
@@ -1447,7 +1543,12 @@ def _synthesize_from(self, spec_path: Path, spec: SessionSpec) -> devcontract.Sy | |
| story_key = spec.env.get("BMAD_LOOP_STORY_KEY") or None | ||
| raw_dw_ids = (spec.env.get("BMAD_LOOP_DW_IDS") or "").split(",") | ||
| dw_ids = [tok for tok in (i.strip() for i in raw_dw_ids) if tok] | ||
| return devcontract.synthesize_result(spec_path, story_key=story_key, dw_ids=dw_ids or None) | ||
| return devcontract.synthesize_result( | ||
| spec_path, | ||
| story_key=story_key, | ||
| dw_ids=dw_ids or None, | ||
| park_marker_session_authored=self._park_marker_session_authored(spec_path, spec), | ||
| ) | ||
|
|
||
| def _observe_tick(self, handle: SessionHandle, spec: SessionSpec) -> None: | ||
| """Mid-session status-transition observation (#276 M2), called each | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On supported Python 3.11,
Path.resolve()raisesRuntimeErrorfor a symlink loop, but this fallback catches onlyOSError. Consequently, a single looped*.mdsymlink in an artifact directory makes_capture_launch_auto_run_resultsabort before the transport starts, blocking every unpinned generic dev session instead of treating that unrelated marker as unreadable; catchRuntimeErrorhere as the surrounding path-observation code does.AGENTS.md reference: AGENTS.md:L19-L19
Useful? React with 👍 / 👎.