159 lines
6.9 KiB
Python
159 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Queue a trusted same-PR Hermes continuation from an existing root card."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from typing import Any
|
|
|
|
import scm_broker_client
|
|
import supervisor_state
|
|
|
|
|
|
BOARD = re.compile(r"[a-z0-9][a-z0-9-]{0,63}\Z")
|
|
TASK = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z")
|
|
PR_URL = re.compile(
|
|
r"https://scm\.bstein\.dev/titan/(?P<project>[A-Za-z0-9][A-Za-z0-9_.-]{0,99})/pulls/(?P<number>[1-9][0-9]{0,9})\Z"
|
|
)
|
|
MAX_OBJECTIVE = 8_000
|
|
|
|
|
|
class ContinueError(ValueError):
|
|
"""Reject a continuation that cannot be tied to durable coordinator state."""
|
|
|
|
|
|
def _text(value: Any) -> str:
|
|
return value.strip() if isinstance(value, str) else ""
|
|
|
|
|
|
def _validated_args(board: str, root_task: str, objective: str) -> tuple[str, str, str]:
|
|
"""Keep the CLI surface limited to a board, root ID, and user objective."""
|
|
board, root_task, objective = _text(board), _text(root_task), _text(objective)
|
|
if not BOARD.fullmatch(board) or not TASK.fullmatch(root_task):
|
|
raise ContinueError("board or root task ID is invalid")
|
|
if not objective or len(objective) > MAX_OBJECTIVE or "\x00" in objective:
|
|
raise ContinueError("objective must be non-empty and at most 8000 characters")
|
|
return board, root_task, objective
|
|
|
|
|
|
def _live_head(lineage: Any) -> str:
|
|
"""Prove the exact open, same-repository PR that the root recorded."""
|
|
match = PR_URL.fullmatch(lineage.pull_request)
|
|
if match is None or match.group("project") != lineage.project:
|
|
raise ContinueError("root lineage does not carry a canonical pull request")
|
|
try:
|
|
document = json.loads(scm_broker_client.read(
|
|
f"/api/v1/repos/titan/{lineage.project}/pulls/{match.group('number')}"
|
|
))
|
|
except Exception as error: # noqa: BLE001 - read proof must fail closed
|
|
raise ContinueError(f"live pull-request proof is unavailable: {type(error).__name__}") from error
|
|
if not isinstance(document, dict):
|
|
raise ContinueError("live pull-request proof is malformed")
|
|
head, base = document.get("head"), document.get("base")
|
|
full_name = f"titan/{lineage.project}"
|
|
valid = (
|
|
document.get("state") == "open"
|
|
and isinstance(head, dict) and isinstance(base, dict)
|
|
and _text(head.get("ref")) == lineage.branch
|
|
and isinstance(head.get("repo"), dict) and head["repo"].get("full_name") == full_name
|
|
and _text(base.get("ref")) == lineage.base_branch
|
|
and isinstance(base.get("repo"), dict) and base["repo"].get("full_name") == full_name
|
|
)
|
|
sha = _text(head.get("sha")) if isinstance(head, dict) else ""
|
|
if not valid or not re.fullmatch(r"[0-9a-fA-F]{7,64}", sha):
|
|
raise ContinueError("live pull request no longer matches trusted branch, base, and repository")
|
|
return sha
|
|
|
|
|
|
def _body(root_task: str, lineage: Any, head: str, objective: str) -> str:
|
|
"""Retain human-visible PR/root context; authority remains in supervisor state."""
|
|
return (
|
|
"Hermes-Task-Role: repair\n\n"
|
|
f"Trusted continuation of root task {root_task} on {lineage.pull_request}.\n"
|
|
f"Verified current PR head: {head}\n\n"
|
|
"Use the coordinator-issued private workspace from the latest remote branch. "
|
|
"Keep this existing PR and branch; do not create a branch or PR. Inspect the "
|
|
"current diff, implement and verify the requested repair, then submit through "
|
|
"the signed mediator path.\n\n"
|
|
f"Objective:\n{objective}\n"
|
|
)
|
|
|
|
|
|
def queue(
|
|
kanban_db: Any, *, board: str, root_task: str, objective: str
|
|
) -> tuple[str, bool]:
|
|
"""Create one idempotent root-parent repair card using only trusted lineage."""
|
|
board, root_task, objective = _validated_args(board, root_task, objective)
|
|
with kanban_db.scoped_current_board(board):
|
|
conn = kanban_db.connect(board=board)
|
|
try:
|
|
root = kanban_db.get_task(conn, root_task)
|
|
if root is None:
|
|
raise ContinueError("root task does not exist on the requested board")
|
|
lineage = supervisor_state.get_root(board, root_task)
|
|
if lineage is None or lineage.root_task_id != root_task:
|
|
raise ContinueError("root task has no coordinator-issued PR lineage")
|
|
head = _live_head(lineage)
|
|
existing = supervisor_state.existing_child(board, root_task, head, objective)
|
|
if existing:
|
|
return existing, False
|
|
# A live read proved this is the current same PR. It invalidates any
|
|
# prior approval before the new child can be dispatched.
|
|
supervisor_state.record_live_head(board, root_task, head)
|
|
key = f"supervisor:continue:{root_task}:{head}:{supervisor_state.objective_digest(objective)}"
|
|
child_id = kanban_db.create_task(
|
|
conn,
|
|
title=f"Continue PR {lineage.pull_request.rsplit('/', 1)[-1]}: {objective[:96]}",
|
|
body=_body(root_task, lineage, head, objective),
|
|
assignee="cli-auto",
|
|
created_by="hermes-supervisor",
|
|
parents=[root_task],
|
|
idempotency_key=key,
|
|
# Native Hermes derives ready/todo from the root parent; its
|
|
# only non-blocked initial status is the explicit running mode.
|
|
initial_status="running",
|
|
)
|
|
if not isinstance(child_id, str) or not child_id:
|
|
raise ContinueError("Kanban did not return a continuation task ID")
|
|
supervisor_state.record_child(
|
|
board, child_id, root_task, root_task, "repair", head, objective
|
|
)
|
|
supervisor_state.clear_ready(board, root_task)
|
|
try:
|
|
kanban_db.add_comment(
|
|
conn, root_task, "hermes-supervisor",
|
|
f"supervisor: queued trusted continuation {child_id} for current PR head {head}.",
|
|
)
|
|
except Exception:
|
|
pass
|
|
return child_id, True
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Parse a minimal local CLI then queue a continuation or print its ID."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--board", required=True)
|
|
parser.add_argument("--root-task", required=True)
|
|
parser.add_argument("--objective", required=True)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
from hermes_cli import kanban_db
|
|
|
|
child, created = queue(
|
|
kanban_db, board=args.board, root_task=args.root_task, objective=args.objective
|
|
)
|
|
except ContinueError as error:
|
|
print(f"kanban continuation rejected: {error}", file=sys.stderr)
|
|
return 2
|
|
print(json.dumps({"task_id": child, "created": created}, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - process entry point
|
|
raise SystemExit(main())
|