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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,19 @@ review-depth, HEAD и два одинаковых полных GraphQL snapshot
сохранении точной цели в content allowlist. Pending MERGE cleanup закрывает gate
и возвращается в recovery следующим запуском.

Для cross-repo sync проект может выдать content-кандидат со stage
`pre-review-validation` и объектом `pre_review_sync`: положительные int64
`intent_comment_id`/`done_comment_id`, lowercase SHA-1 `from`/`to`/`base`,
lowercase SHA-256 `identity_sha256` и RFC3339 `intent_created_at`/
`done_created_at`. Набор из восьми полей точный, `head == to`, а done должен
быть позже intent. Такой кандидат никогда не бывает `integration_owner` или
`merge_executable` и требует `fallback_handoff: "target-v1"`: HMAC lease
связывает все поля, но не заменяет проверку происхождения. До gate полный скилл,
оставаясь read-only, обязан сначала доказать provenance стабильными GraphQL-
снимками, затем провести обычное полное ревью всего diff и тестов. Свежий gate
выполняется ровно один раз, когда аудит закончен и агент готов к первой мутации;
быстрый `action=audit`/`complete review` для этого stage запрещён.

`action=validated` — только read-only scheduling proof; ответ явно содержит
`mutation_authorized=false`. Все GraphQL, ship, CI, base-sync и CAS-проверки
остаются в полном скилле. Handoff обслуживает один PR и при отказе не переходит
Expand Down
72 changes: 62 additions & 10 deletions promptpilot/fallback_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,39 @@

import re
import time
from datetime import datetime


PROTOCOL = "promptpilot-fallback-target-v1"
REVIEW_STAGES = {"review", "integration-review", "legacy-integration-review"}
PRE_REVIEW_VALIDATION_STAGE = "pre-review-validation"
PRE_REVIEW_SYNC_FIELDS = (
"intent_comment_id", "done_comment_id", "from", "to", "base",
"identity_sha256", "intent_created_at", "done_created_at",
)
CONTENT_REVIEW_STAGES = {"review", PRE_REVIEW_VALIDATION_STAGE}
INTEGRATION_REVIEW_STAGES = {"integration-review", "legacy-integration-review"}
REVIEW_STAGES = CONTENT_REVIEW_STAGES | INTEGRATION_REVIEW_STAGES
MERGE_STAGES = {"merge", "integration-merge-ready", "legacy-integration-merge-ready",
"integration-merge-recovery"}
INTEGRATION_MERGE_STAGES = MERGE_STAGES - {"merge"}
INTEGRATION_STAGES = INTEGRATION_REVIEW_STAGES | INTEGRATION_MERGE_STAGES
TTL = 7200


def _rfc3339(value: object) -> datetime | None:
if (not isinstance(value, str)
or not re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})",
value,
)):
return None
try:
return datetime.fromisoformat(
value[:-1] + "+00:00" if value.endswith("Z") else value)
except ValueError:
return None


def identity(value: dict) -> dict:
from .project_pipeline import PipelineError

Expand All @@ -26,7 +50,35 @@ def identity(value: dict) -> dict:
or not isinstance(value.get("stage"), str)
or value.get("stage") not in REVIEW_STAGES | MERGE_STAGES):
raise PipelineError("fallback target requires exact PR, HEAD and stage")
return {key: value[key] for key in ("number", "head", "stage")}
target = {key: value[key] for key in ("number", "head", "stage")}
metadata = value.get("pre_review_sync")
if value["stage"] != PRE_REVIEW_VALIDATION_STAGE:
if metadata is not None:
raise PipelineError("fallback target has pre-review metadata on another stage")
return target
if not isinstance(metadata, dict) or set(metadata) != set(PRE_REVIEW_SYNC_FIELDS):
raise PipelineError("pre-review validation requires exact sync metadata")
if any(type(metadata[field]) is not int
or not 0 < metadata[field] <= 2**63 - 1
for field in ("intent_comment_id", "done_comment_id")):
raise PipelineError("pre-review validation requires positive comment IDs")
if any(not isinstance(metadata[field], str)
or not re.fullmatch(r"[0-9a-f]{40}", metadata[field])
for field in ("from", "to", "base")):
raise PipelineError("pre-review validation requires exact commit identities")
if (not isinstance(metadata["identity_sha256"], str)
or not re.fullmatch(r"[0-9a-f]{64}", metadata["identity_sha256"])):
raise PipelineError("pre-review validation requires an exact identity digest")
intent_created = _rfc3339(metadata["intent_created_at"])
done_created = _rfc3339(metadata["done_created_at"])
if intent_created is None or done_created is None:
raise PipelineError("pre-review validation requires RFC3339 timestamps")
if done_created <= intent_created:
raise PipelineError("pre-review sync completion must follow its intent")
if value["head"] != metadata["to"]:
raise PipelineError("pre-review validation HEAD does not match sync destination")
target["pre_review_sync"] = {field: metadata[field] for field in PRE_REVIEW_SYNC_FIELDS}
return target


def validate_health(health: dict) -> None:
Expand All @@ -44,7 +96,7 @@ def validate_health(health: dict) -> None:
barriers = [item for item in findings if item.get("code") == "single_flight_barrier"]
if owner is not None:
owner = identity(owner)
if (owner["stage"] in {"review", "merge"} or len(barriers) != 1
if (owner["stage"] not in INTEGRATION_STAGES or len(barriers) != 1
or type(barriers[0].get("pr")) is not int
or barriers[0]["pr"] != owner["number"]):
raise PipelineError("fallback owner contradicts the single-flight barrier")
Expand All @@ -53,7 +105,7 @@ def validate_health(health: dict) -> None:

queues = {}
for field, stages in (("review_candidates", REVIEW_STAGES),
("content_review_candidates", {"review"}),
("content_review_candidates", CONTENT_REVIEW_STAGES),
("merge_executable", MERGE_STAGES)):
values = health.get(field)
if not isinstance(values, list):
Expand All @@ -66,7 +118,7 @@ def validate_health(health: dict) -> None:
reviewing = queues["review_candidates"]
content = queues["content_review_candidates"]
merging = queues["merge_executable"]
if owner is not None and owner["stage"] in REVIEW_STAGES:
if owner is not None and owner["stage"] in INTEGRATION_REVIEW_STAGES:
if reviewing != [owner] or merging:
raise PipelineError("fallback integration REVIEW contradicts executable queues")
else:
Expand Down Expand Up @@ -100,7 +152,7 @@ def rest_only_review_owner(health: dict) -> bool:
owner = identity(compatible.get("integration_owner"))
except PipelineError:
return False
return owner["stage"] in {"integration-review", "legacy-integration-review"}
return owner["stage"] in INTEGRATION_REVIEW_STAGES


def health_gate(health: dict, stage: str, target: dict, *, election: bool) -> None:
Expand All @@ -115,7 +167,7 @@ def health_gate(health: dict, stage: str, target: dict, *, election: bool) -> No
owner = identity(owner) if owner is not None else None

field = "review_candidates" if stage == "review" else "merge_executable"
if stage == "review" and expected["stage"] == "review" and not election:
if stage == "review" and expected["stage"] in CONTENT_REVIEW_STAGES and not election:
field = "content_review_candidates"
candidates = health.get(field)
if not isinstance(candidates, list) or not candidates:
Expand All @@ -125,18 +177,18 @@ def health_gate(health: dict, stage: str, target: dict, *, election: bool) -> No
raise PipelineError("fallback allowlist contains duplicate PRs")
if expected not in actual or ((election or stage == "merge") and actual[0] != expected):
raise PipelineError("fallback target no longer matches the exact executable candidate")
if expected["stage"] not in {"review", "merge"}:
if expected["stage"] in INTEGRATION_STAGES:
if owner != expected or actual != [expected]:
raise PipelineError("fallback integration target is not the sole executable owner")
if stage == "review" and health.get("merge_executable") != []:
raise PipelineError("fallback integration REVIEW contradicts merge_executable")
elif stage == "merge" and owner is not None:
raise PipelineError("fallback ordinary merge is blocked by an integration owner")
if stage == "review" and expected["stage"] == "review":
if stage == "review" and expected["stage"] in CONTENT_REVIEW_STAGES:
content = health.get("content_review_candidates")
if not isinstance(content, list) or expected not in [identity(item) for item in content]:
raise PipelineError("fallback content target was not proved by health")
if election and owner is not None and owner["stage"] in REVIEW_STAGES:
if election and owner is not None and owner["stage"] in INTEGRATION_REVIEW_STAGES:
raise PipelineError("fallback content election bypasses integration REVIEW")


Expand Down
19 changes: 18 additions & 1 deletion promptpilot/pipeline_insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -2359,7 +2359,7 @@ def execution_route(task, fallback_prompt: str, working_dir: str | None = None,
from .project_pipeline import PipelineError

try:
validate(preflight, stage)
fallback_lease = validate(preflight, stage)
if command[-2:] != ["next", stage]:
raise PipelineError("fallback handoff command must end with next and the exact stage")
except (PipelineError, TypeError, ValueError) as exc:
Expand All @@ -2374,10 +2374,27 @@ def execution_route(task, fallback_prompt: str, working_dir: str | None = None,
envelope = {"protocol": "promptpilot-fallback-target-v1",
"next_already_run": True, "command": command,
"gate_command": gate_command, "preflight": preflight}
pre_review_guard = ""
if fallback_lease["target"]["stage"] == "pre-review-validation":
pre_review_guard = (
"Это специальный content-lane этап pre-review-validation, а не "
"integration review. Подписанный envelope только фиксирует все "
"восемь полей pre_review_sync и HEAD; он не доказывает provenance "
"и не разрешает быстрый результат. До gate_command, оставаясь полностью "
"read-only, сначала выполни предусмотренную скиллом полную стабильную "
"GraphQL-проверку происхождения sync-коммита. Только после её успеха "
"проверь весь diff PR как обычное содержательное ревью и выполни все "
"уместные полные тесты. Этот target нельзя завершать через быстрый "
"action=audit или `complete review`. Лишь когда provenance и аудит "
"полностью завершены и ты готов к первой мутации, переходи к описанному "
"ниже одноразовому gate_command непосредственно перед этой мутацией. "
"При любой ошибке provenance остановись без gate и без мутаций.\n\n"
)
prompt = (
"PromptPilot уже выполнил election next. Не запускай next повторно "
"и не выбирай другую цель. Полностью прочитай канонический скилл "
"и его legacy-протокол. Используй только exact target из envelope. "
f"{pre_review_guard}"
"Непосредственно перед первой мутацией выполни gate_command ровно один "
"раз: он заново "
"запускает полный pipelinehealth и проверяет ту же цель. Требуется "
Expand Down
8 changes: 8 additions & 0 deletions promptpilot/project_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,14 @@ def next_review(gh: GitHub, config: dict, *, config_path: str | None = None) ->
if not candidates:
return {"action": "empty", "verdict": "ПУСТО", "reason": review_empty_reason(health)}
item = candidates[0]
if item.get("stage") == "pre-review-validation":
if config.get("fallback_handoff") != "target-v1":
raise PipelineError(
"pre-review validation requires the exact-target fallback protocol")
return fallback_target(
config, health, "review", item,
"pre-review sync provenance requires validation and a full content review",
)
if item.get("stage") != "review":
return fallback_target(config, health, "review", item,
"integration/base-sync state requires the full skill")
Expand Down
58 changes: 58 additions & 0 deletions tests/fixtures/fallback-handoff-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,64 @@
}
]
}
},
{
"stage": "review",
"target": {
"number": 84,
"head": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"stage": "pre-review-validation",
"pre_review_sync": {
"intent_comment_id": 1001,
"done_comment_id": 1002,
"from": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"to": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"identity_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"intent_created_at": "2026-09-17T08:00:00Z",
"done_created_at": "2026-09-17T08:02:00Z"
}
},
"health": {
"state": "green",
"findings": [],
"integration_owner": null,
"review_candidates": [
{
"number": 84,
"head": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"stage": "pre-review-validation",
"pre_review_sync": {
"intent_comment_id": 1001,
"done_comment_id": 1002,
"from": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"to": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"identity_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"intent_created_at": "2026-09-17T08:00:00Z",
"done_created_at": "2026-09-17T08:02:00Z"
}
}
],
"content_review_candidates": [
{
"number": 84,
"head": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"stage": "pre-review-validation",
"pre_review_sync": {
"intent_comment_id": 1001,
"done_comment_id": 1002,
"from": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"to": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"identity_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"intent_created_at": "2026-09-17T08:00:00Z",
"done_created_at": "2026-09-17T08:02:00Z"
}
}
],
"merge_executable": []
}
}
]
}
Loading
Loading