Skip to content

Commit 2973a5e

Browse files
authored
Merge pull request #1111 from Serhan-Asad/fix/1106-metadata-finalization-alignment
fix: align metadata finalization skip/fail semantics for update and CI heal
2 parents 6dc4826 + de445f6 commit 2973a5e

7 files changed

Lines changed: 509 additions & 41 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2412,7 +2412,7 @@ Options:
24122412
- `--git`: Use git history to find the original code file, eliminating the need for the `INPUT_CODE_FILE` argument.
24132413
- `--extensions EXTENSIONS`: In repository-wide mode, filter the update to only include files with the specified comma-separated extensions (e.g., `py,js,ts`).
24142414
- `--simple`: Use the legacy 2-stage LLM update process instead of the default agentic mode. Useful when agentic CLIs are not available or for faster updates.
2415-
- `--sync-metadata`: After the prompt update, run the shared metadata-sync orchestrator so prompt PDD tags, `architecture.json` entries, run reports, and fingerprint state are reconciled in one step. Works in single-file, regeneration, and repo modes. **Fingerprint note:** default single-file/regeneration `pdd update <code>` already finalizes the per-target fingerprint (`.pdd/meta/<basename>_<language>.json`) on success, and logs a skip reason when finalization is intentionally bypassed; `--sync-metadata` does not gate that behavior. Default repo-mode `pdd update --repo` likewise finalizes per-pair fingerprints and, before writing each fingerprint, clears the affected module's stale `.pdd/meta/<basename>_<language>_run.json` runtime-verification report so metadata and runtime state stay in lock-step (clear failures are surfaced as non-fatal warnings, and if the stale `_run.json` still exists after the clear attempt the fingerprint write is skipped so a fresh fingerprint cannot coexist with stale runtime state — closing issue [#1057](https://github.com/promptdriven/pdd/issues/1057)). Without this flag, the broader prompt-tag/architecture/run-report orchestrator is not run and those layers must be reconciled with separate commands. **Scope note:** the `tags` stage currently *preserves* existing PDD tags and only *seeds* tags from the matching `architecture.json` entry when a prompt has none — LLM-first **refresh** of stale-but-present tags is tracked at issue [#870](https://github.com/promptdriven/pdd/issues/870) and is not invoked by this orchestrator. When a prompt has zero PDD tags AND no architecture entry, the `tags` stage reports `skipped` (never `ok`) so operators see honest status. On any stage `failed`, `pdd update --sync-metadata` exits non-zero so CI auto-heal does not treat a half-finalized update as healed.
2415+
- `--sync-metadata`: After the prompt update, run the shared metadata-sync orchestrator so prompt PDD tags, `architecture.json` entries, run reports, and fingerprint state are reconciled in one step. Works in single-file, regeneration, and repo modes. **Fingerprint note:** default single-file/regeneration `pdd update <code>` already finalizes the per-target fingerprint (`.pdd/meta/<basename>_<language>.json`) on success, and logs a skip reason when finalization is intentionally bypassed; `--sync-metadata` does not gate that behavior. Before writing the single-file/regeneration fingerprint, the command clears the affected module's stale `.pdd/meta/<basename>_<language>_run.json` runtime-verification report and re-checks it is gone; if the stale report survives the clear attempt (for example, silent `os.remove` failure on permissions/locks), the fingerprint write is skipped with a yellow warning so a fresh fingerprint cannot coexist with stale runtime state — best-effort, the successful `(prompt, cost, model)` update tuple is preserved either way (closing issue [#1106](https://github.com/promptdriven/pdd/issues/1106)). The stale-report warning intentionally surfaces even under `--quiet`, because it describes a real metadata-consistency problem the operator should learn about regardless of other log suppression. Default repo-mode `pdd update --repo` likewise finalizes per-pair fingerprints and, before writing each fingerprint, clears the affected module's stale `.pdd/meta/<basename>_<language>_run.json` runtime-verification report so metadata and runtime state stay in lock-step (clear failures are surfaced as non-fatal warnings, and if the stale `_run.json` still exists after the clear attempt the fingerprint write is skipped so a fresh fingerprint cannot coexist with stale runtime state — closing issue [#1057](https://github.com/promptdriven/pdd/issues/1057)). Without this flag, the broader prompt-tag/architecture/run-report orchestrator is not run and those layers must be reconciled with separate commands. **Scope note:** the `tags` stage currently *preserves* existing PDD tags and only *seeds* tags from the matching `architecture.json` entry when a prompt has none — LLM-first **refresh** of stale-but-present tags is tracked at issue [#870](https://github.com/promptdriven/pdd/issues/870) and is not invoked by this orchestrator. When a prompt has zero PDD tags AND no architecture entry, the `tags` stage reports `skipped` (never `ok`) so operators see honest status. On any stage `failed`, `pdd update --sync-metadata` exits non-zero so CI auto-heal does not treat a half-finalized update as healed.
24162416
24172417
Example (Metadata Sync):
24182418
```bash

pdd/ci_drift_heal.py

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,36 +1251,57 @@ def _heal_update(drift: DriftInfo, env: Dict[str, str], skip_set: Set[str]) -> O
12511251
drift.prompt_path = str(candidate)
12521252
prompt_path = drift.prompt_path
12531253

1254+
# Issue #1106 Gap 2: the lazy block above only fail-closes when
1255+
# `drift.prompt_path` was initially None. If it was set on `drift`
1256+
# BEFORE this heal ran but the file is missing on disk after the
1257+
# `pdd update` subprocess (typo, deleted, renamed by update, language
1258+
# detection mismatch), the previous code's `if prompt_exists:` guard
1259+
# further down silently skipped `_run_metadata_sync_safe` AND still
1260+
# fell through to the follow-up `pdd example` — masking metadata
1261+
# failure as a successful heal. Pull that gate up here so the missing
1262+
# case mirrors the lazy-unresolvable case: explicit hard failure, no
1263+
# metadata sync, no follow-up example.
1264+
try:
1265+
prompt_exists_post_update = Path(str(prompt_path)).exists()
1266+
except Exception:
1267+
prompt_exists_post_update = False
1268+
if not prompt_exists_post_update:
1269+
console.print(
1270+
f"[red]heal failed for {drift.basename}: prompt_path "
1271+
f"{prompt_path} set but missing on disk post-update[/red]"
1272+
)
1273+
drift.metadata_finalization_failed = True
1274+
drift.metadata_finalization_error = (
1275+
"prompt_path set but missing on disk post-update"
1276+
)
1277+
return False
1278+
12541279
# Gates.
12551280
if not _enforce_prompt_churn_gate(drift):
12561281
return False
12571282
if not _enforce_structural_invariants(drift):
12581283
return False
12591284

1260-
# Snapshot + metadata orchestrator (only when prompt file exists on disk).
1261-
snapshot: Optional[Dict[str, Optional[bytes]]] = None
1262-
try:
1263-
prompt_exists = Path(str(prompt_path)).exists()
1264-
except Exception:
1265-
prompt_exists = False
1266-
if prompt_exists:
1267-
snapshot = _snapshot_metadata_state_for(drift)
1268-
meta_ok = _run_metadata_sync_safe(str(prompt_path), str(code_path) if code_path else None)
1269-
if not meta_ok:
1270-
try:
1271-
_revert_prompt_file(drift)
1272-
except PromptRevertError:
1273-
raise
1274-
if snapshot is not None:
1275-
_restore_metadata_state_for(snapshot)
1276-
# Metadata finalization is a hard requirement (Issue #1006): a
1277-
# successful auto-heal commit must include the updated fingerprint,
1278-
# so this failure must surface distinctly from advisory subprocess
1279-
# failures and fail the run loudly in every mode.
1280-
drift.metadata_finalization_failed = True
1281-
drift.metadata_finalization_error = "metadata sync returned false"
1282-
return False
1283-
drift.metadata_finalized = True
1285+
# Snapshot + metadata orchestrator. Prompt-exists was verified above, so
1286+
# the previous `if prompt_exists:` guard is now unconditional — keep the
1287+
# snapshot/revert flow inline.
1288+
snapshot = _snapshot_metadata_state_for(drift)
1289+
meta_ok = _run_metadata_sync_safe(str(prompt_path), str(code_path) if code_path else None)
1290+
if not meta_ok:
1291+
try:
1292+
_revert_prompt_file(drift)
1293+
except PromptRevertError:
1294+
raise
1295+
if snapshot is not None:
1296+
_restore_metadata_state_for(snapshot)
1297+
# Metadata finalization is a hard requirement (Issue #1006): a
1298+
# successful auto-heal commit must include the updated fingerprint,
1299+
# so this failure must surface distinctly from advisory subprocess
1300+
# failures and fail the run loudly in every mode.
1301+
drift.metadata_finalization_failed = True
1302+
drift.metadata_finalization_error = "metadata sync returned false"
1303+
return False
1304+
drift.metadata_finalized = True
12841305

12851306
# Optional follow-up: skip when module bypassed via env.
12861307
if drift.basename in skip_set:

pdd/prompts/ci_drift_heal_python.prompt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ A standalone CI script (`pdd/ci_drift_heal.py`) that orchestrates drift detectio
5959

6060
7. **Noninteractive strength override:** All heal subprocess commands pass `--force --strength 0.5`. `--force` suppresses overwrite prompts in CI; `--strength 0.5` overrides `.pddrc` context strengths that would otherwise push model selection into expensive tiers.
6161

62-
8. **Lazy prompt_path resolution:** For the code-without-prompt flow, `prompt_path` starts as None. After `pdd update` creates the prompt, resolve `prompt_path` via `get_pdd_file_paths()` before running the follow-up example step and gates. Fail closed if prompt_path remains unresolvable post-update.
62+
8. **Lazy prompt_path resolution and post-update existence gate:** For the code-without-prompt flow, `prompt_path` starts as None. After `pdd update` creates the prompt, resolve `prompt_path` via `get_pdd_file_paths()` before running the follow-up example step and gates. **Fail closed in both shapes of missing prompt post-update** (issue #1106): (a) when `prompt_path` was initially None and remains unresolvable after the lazy block — set `drift.metadata_finalization_failed = True`, `drift.metadata_finalization_error = "prompt_path unresolvable post-update"`, log `[red]heal failed for <basename>: prompt_path unresolvable post-update[/red]`, and return False; and (b) when `prompt_path` was initially set on `drift` but `Path(prompt_path).exists()` is False after the `pdd update` subprocess (typo, deleted/renamed by update, language detection mismatch) — set `drift.metadata_finalization_failed = True`, set `drift.metadata_finalization_error` to an explicit reason such as `"prompt_path set but missing on disk post-update"`, log `[red]heal failed for <basename>: prompt_path <prompt_path> set but missing on disk post-update[/red]`, and return False. In both cases the heal MUST NOT call `_run_metadata_sync_safe` and MUST NOT run the follow-up `pdd example` subprocess — otherwise a heal can silently mask an inconsistent post-update state by completing the example step with no metadata sync. Place the preset-but-missing existence gate AFTER the lazy-resolution block and BEFORE the churn / structural-invariants gates so a stable check order applies to both branches.
6363

6464
9. **Prompt churn gate:** After `pdd update`, compare prompt churn (lines added+deleted vs HEAD) against code churn (vs diff_base). If ratio exceeds `_HEAL_PROMPT_CHURN_MAX_RATIO` (default 5.0, env-overridable via `PDD_HEAL_PROMPT_CHURN_MAX_RATIO`), revert the prompt and fail. Permissive when inputs are missing or git fails — let structural invariants be the fallback guard.
6565

pdd/prompts/update_main_python.prompt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Supports three modes: true update, regeneration, and repository-wide updates. Ro
7474
- If the written prompt path differs from the canonical source prompt (i.e. `--output` redirected the write away from the input prompt in true-update mode), do not finalize metadata and print `[info][metadata] Skipping fingerprint finalization: output redirected[/info]` unless `quiet`. This guard applies in addition to (and independently of) the `sync_metadata=True` skip — when both are set, the orchestrator call itself is also skipped per Requirement 14 so neither layer records a fingerprint against the redirected path.
7575
- If `infer_module_identity(prompt_path)` returns `(None, None)`, do not finalize metadata and print `[info][metadata] Skipping fingerprint finalization: unable to infer module identity for <prompt_path>[/info]` unless `quiet`.
7676
- If `save_fingerprint` raises, keep the successful return tuple and print `[warning][metadata] Fingerprint save failed: <exc>[/warning]` unless `quiet`.
77-
- Existing stale `_run.json` cleanup behavior must remain unchanged.
77+
- Stale `_run.json` cleanup MUST reuse `pdd.operation_log._clear_run_report_before_fingerprint(basename, language)`, which clears the run report then re-checks existence and returns `False` (with a yellow console warning) when a silent `os.remove` left it on disk. When the helper returns `False`, skip `save_fingerprint` so a fresh fingerprint never coexists with stale runtime state (issue #1106; mirrors repo-mode and the `log_operation` decorator). The helper's warning surfaces unconditionally — it does NOT honour the caller's `quiet` flag, intentionally, because it describes a real metadata problem. Wrap the helper call in `try/except`: on an unexpected exception, emit `[warning][metadata] Run report clear failed: <exc>[/warning]` unless `quiet` and skip `save_fingerprint` (best-effort cleanup must never break the successful update tuple). The function-local `from .operation_log import (_clear_run_report_before_fingerprint, infer_module_identity, save_fingerprint)` MUST also be wrapped in `try/except ImportError`: on ImportError, emit `[warning][metadata] Could not import finalization helpers: <exc>[/warning]` unless `quiet` and return without calling `save_fingerprint`. This is required because `_clear_run_report_before_fingerprint` is a private underscore-prefixed symbol and therefore more fragile to internal `operation_log` renames than the public names alongside it — without the import guard, an ImportError would propagate to `update_main`'s outer `except Exception: return None` and silently convert a successful `(prompt, cost, model)` tuple to `None`, violating the "best-effort metadata cleanup must never break the successful update tuple" contract.
7878

7979
% Modes
8080
1. **True update**: `input_prompt_file` + `modified_code_file` + (use_git OR `input_code_file`).

pdd/update_main.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,11 +1103,29 @@ def _finalize_single_file_fingerprint(
11031103
)
11041104
return
11051105

1106-
from .operation_log import (
1107-
clear_run_report,
1108-
infer_module_identity,
1109-
save_fingerprint,
1110-
)
1106+
# Wrap the import itself so the user's successful update tuple is never
1107+
# broken by an import-time failure (e.g. `_clear_run_report_before_fingerprint`
1108+
# gets renamed in a future operation_log refactor — it's a private
1109+
# underscore-prefixed name and therefore more fragile than the public
1110+
# `clear_run_report` / `infer_module_identity` / `save_fingerprint`
1111+
# alongside it). An ImportError raised here would propagate up to
1112+
# `update_main`'s outer `except Exception: return None`, converting a
1113+
# successful `(prompt, cost, model)` tuple to None — which violates the
1114+
# issue #1106 acceptance criterion: best-effort metadata cleanup must
1115+
# never fail the successful update tuple.
1116+
try:
1117+
from .operation_log import (
1118+
_clear_run_report_before_fingerprint,
1119+
infer_module_identity,
1120+
save_fingerprint,
1121+
)
1122+
except ImportError as exc:
1123+
if not quiet:
1124+
rprint(
1125+
f"[warning][metadata] Could not import finalization helpers: "
1126+
f"{exc}[/warning]"
1127+
)
1128+
return
11111129
basename, language = infer_module_identity(prompt_path)
11121130
if not (basename and language):
11131131
if not quiet:
@@ -1117,13 +1135,33 @@ def _finalize_single_file_fingerprint(
11171135
)
11181136
return
11191137

1138+
# Reuse the shared helper so the single-file finalize path enforces the
1139+
# same invariant the `log_operation` decorator and repo-mode block already
1140+
# do: a fresh fingerprint must never coexist with a stale `_run.json`
1141+
# (issue #1106). The helper re-checks that the run report is actually
1142+
# gone after `clear_run_report()` and emits a console warning if a
1143+
# silent `os.remove` failure left it behind — see
1144+
# `pdd.operation_log._clear_run_report_before_fingerprint`. The warning
1145+
# surfaces unconditionally (the helper does not consult `quiet`): the
1146+
# contract the issue text quotes is "print a warning" without qualifying
1147+
# on quiet mode, and the user should learn that runtime verification
1148+
# state still describes the pre-mutation files even when --quiet
1149+
# suppresses other chatter (why: informational about a real metadata
1150+
# problem, not status fluff).
11201151
try:
1121-
clear_run_report(basename, language)
1152+
fingerprint_allowed = _clear_run_report_before_fingerprint(basename, language)
11221153
except Exception as exc:
1154+
# Defensive: surrounding pattern in this function treats metadata
1155+
# cleanup as best-effort; an unexpected raise must not break the
1156+
# successful update tuple. Warn and skip the save, matching the
1157+
# `save_fingerprint` except-arm below.
11231158
if not quiet:
11241159
rprint(
11251160
f"[warning][metadata] Run report clear failed: {exc}[/warning]"
11261161
)
1162+
return
1163+
if not fingerprint_allowed:
1164+
return
11271165

11281166
try:
11291167
save_fingerprint(

0 commit comments

Comments
 (0)