Activates and completes the previously dead "analyst asks BLOCKING questions -> 01-questions.md -> Needs Input" path. Four coordinated changes, additive, under kill-switch, self-hosting scope, never-raise; STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys / DB schema are byte-for-byte UNCHANGED (the flow is a pre-gate engine branch, NOT a Quality Gate; 01-questions.md is a SIGNAL artifact, NOT a machine-verdict). - D1 contract + canon: analyst.md documents the 01-questions.md channel (blocking questions -> Needs Input, do NOT fabricate deliverables) + resume behaviour; new skeleton docs/_templates/01-questions.md; PIPELINE_DOCS.md manifest row + 01- prefix note. - D2 freshness-supersede (DQ-2): pure offline mtime predicate questions_active in the new leaf src/analyst_questions.py (a full FRESH package supersedes a stale untouched 01-questions.md -> no Needs-Input loop, AC-6). - D3 priority: questions take priority over "files ready" in _handle_analysis_approved_flow (_decide_analysis_outcome + _emit_analysis_*); off/out-of-scope runs the ORIGINAL byte-for-byte order (AC-9). - D4 auto-park: set_task_paused on Needs Input via the ORCH-124 pause axis so the repo serial-gate FIFO is not wedged while waiting for a human (AC-4); D5 resume + unpark (clear_task_paused) in handle_status_start (analysis branch). Flags (config.py, safe defaults): analyst_questions_gate_enabled / analyst_questions_gate_repos (empty -> self-hosting only) / analyst_needs_input_autopause_enabled. Tests: test_orch120_analyst_needs_input.py (TC-01 regress + TC-02/03/06/09/10), test_orch120_serial_gate_needs_input.py (TC-04), test_orch120_resume_unpark.py (TC-05), test_orch120_questions_artifact_canon.py (TC-08), assert in test_agent_prompts_canon.py (TC-07). Full suite green (2205 passed). Refs: ORCH-120 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
8.0 KiB
Python
170 lines
8.0 KiB
Python
"""ORCH-120 (adr-0053): analyst open-questions -> Needs Input — pure leaf helpers.
|
|
|
|
Activates and completes the dead "analyst asks BLOCKING questions ->
|
|
``01-questions.md`` -> Needs Input" path in
|
|
``stage_engine._handle_analysis_approved_flow``. This module holds ONLY the pure,
|
|
unit-testable decision logic; the side effects (set_issue_needs_input / Plane
|
|
comment / Telegram / auto-park) stay in ``stage_engine``.
|
|
|
|
Leaf pattern (mirror of ``coverage_gate`` / ``serial_gate`` / ``labels``): imports
|
|
only ``os`` / ``logging`` / ``config`` and lazily ``qg.checks.is_self_hosting_repo``;
|
|
NEVER imports ``stage_engine`` / ``launcher`` / ``db``.
|
|
|
|
What it decides (ADR-001 D2/D3):
|
|
* ``questions_gate_applies(repo)`` — whether the ORCH-120 priority+supersede
|
|
behaviour is REAL for this repo (kill-switch + scope, mirror of
|
|
``coverage_gate_applies``). OFF / out-of-scope -> ``stage_engine`` runs its
|
|
ORIGINAL byte-for-byte order (AC-9).
|
|
* ``autopause_applies(repo)`` — whether the engine auto-parks a task on Needs
|
|
Input (and unparks on resume). Independent sub-tumbler AND the questions gate
|
|
(a task is only ever auto-parked from within the questions-active branch).
|
|
* ``questions_active(worktree, work_item_id, files_ok)`` — the pure freshness-gated
|
|
supersede predicate (DQ-2): are there ACTIVE blocking questions that must win
|
|
over "files ready"?
|
|
|
|
never-raise contract (self-hosting safety): every public function degrades
|
|
conservatively and NEVER propagates into the stage engine / launcher / webhook.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
from .config import settings
|
|
|
|
logger = logging.getLogger("orchestrator.analyst_questions")
|
|
|
|
# The analyst's signal artifact (DQ-4: path kept as-is; the engine already reads
|
|
# exactly this file — see stage_engine._handle_analysis_approved_flow).
|
|
QUESTIONS_FILENAME = "01-questions.md"
|
|
|
|
# The 4 mandatory analysis deliverables that ``check_analysis_complete`` gates on.
|
|
# Used by the mtime freshness-supersede check (DQ-2): a full FRESH package
|
|
# supersedes a stale, untouched 01-questions.md left over from a prior run.
|
|
DELIVERABLES = (
|
|
"01-brd.md",
|
|
"02-trz.md",
|
|
"03-acceptance-criteria.md",
|
|
"04-test-plan.yaml",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conditionality (mirrors coverage_gate_applies / serial_gate_applies)
|
|
# ---------------------------------------------------------------------------
|
|
def questions_gate_applies(repo: str) -> bool:
|
|
"""Whether the ORCH-120 questions priority+supersede is REAL for this repo.
|
|
|
|
Mirrors the ORCH-22 / ORCH-27 / ORCH-43 pattern:
|
|
* ``analyst_questions_gate_enabled=False`` -> always False (kill-switch; the
|
|
engine runs its ORIGINAL pre-ORCH-120 branch order — zero regression, AC-9).
|
|
* ``analyst_questions_gate_repos`` (CSV) non-empty -> real only for the listed
|
|
repos.
|
|
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
|
|
Never raises (AC-10): any error -> False (the safe no-op default that matches
|
|
the kill-switch-off behaviour).
|
|
"""
|
|
try:
|
|
if not getattr(settings, "analyst_questions_gate_enabled", False):
|
|
return False
|
|
raw = (getattr(settings, "analyst_questions_gate_repos", "") or "").strip()
|
|
if raw:
|
|
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
|
|
return (repo or "").strip().lower() in allowed
|
|
# Lazy import keeps this module a leaf (no qg import at module load).
|
|
from .qg.checks import is_self_hosting_repo
|
|
return is_self_hosting_repo(repo)
|
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
|
logger.warning("questions_gate_applies error for %s: %s", repo, e)
|
|
return False
|
|
|
|
|
|
def autopause_applies(repo: str) -> bool:
|
|
"""Whether the engine auto-parks on Needs Input / unparks on resume (D4/D5).
|
|
|
|
Two independent conditions, BOTH required:
|
|
* ``analyst_needs_input_autopause_enabled`` (independent sub-tumbler; False ->
|
|
operator-park only, via ``POST /serial-gate/pause``), AND
|
|
* ``questions_gate_applies(repo)`` — a task is only ever auto-parked from
|
|
within the questions-active branch, so the auto-park scope can never exceed
|
|
the questions gate (keeps the off/out-of-scope path byte-for-byte, AC-9).
|
|
Never raises (AC-10): any error -> False (degrade to operator-park).
|
|
"""
|
|
try:
|
|
if not getattr(settings, "analyst_needs_input_autopause_enabled", False):
|
|
return False
|
|
return questions_gate_applies(repo)
|
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
|
logger.warning("autopause_applies error for %s: %s", repo, e)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure freshness-gated supersede predicate (DQ-2)
|
|
# ---------------------------------------------------------------------------
|
|
def _work_item_dir(worktree_path: str, work_item_id: str) -> str:
|
|
return os.path.join(worktree_path, "docs", "work-items", work_item_id)
|
|
|
|
|
|
def questions_active(worktree_path: str, work_item_id: str, files_ok: bool) -> bool:
|
|
"""Are there ACTIVE blocking questions that must win over "files ready" (DQ-2)?
|
|
|
|
Deterministic and OFFLINE (filesystem only — no network, no git):
|
|
|
|
* ``01-questions.md`` absent -> NOT active (``False``).
|
|
* package incomplete (``files_ok is False``) and the file is present -> active
|
|
(``True``): questions exist, deliverables do not -> questions win (AC-2).
|
|
* package complete (``files_ok is True``) and the file is present -> freshness
|
|
check: **superseded iff ALL 4 deliverables are strictly newer** than
|
|
``01-questions.md`` (by ``os.path.getmtime``). Superseded -> NOT active
|
|
(``False`` -> In Review, AC-6); otherwise -> active (``True`` -> Needs Input,
|
|
AC-1). A full FRESH analyst run always writes the 4 deliverables with a newer
|
|
mtime, so a stale untouched 01-questions.md is deterministically superseded
|
|
without depending on any LLM action.
|
|
|
|
Fail directions (never-raise, AC-10 / DQ-2):
|
|
* a ``getmtime``/comparison error while the file PROVABLY exists -> treat
|
|
questions as **active** (``True``, Needs Input) — safe for "don't build on
|
|
guesses".
|
|
* a catastrophic error (cannot even determine file presence) -> ``False`` so
|
|
``stage_engine`` degrades to its prior ``files_ok`` order + WARNING.
|
|
"""
|
|
try:
|
|
questions_path = os.path.join(
|
|
_work_item_dir(worktree_path, work_item_id), QUESTIONS_FILENAME
|
|
)
|
|
present = os.path.isfile(questions_path)
|
|
except Exception as e: # noqa: BLE001 - catastrophic: cannot determine presence
|
|
logger.warning(
|
|
"questions_active: cannot determine 01-questions.md presence for %s: %s",
|
|
work_item_id, e,
|
|
)
|
|
return False
|
|
|
|
if not present:
|
|
return False
|
|
if not files_ok:
|
|
# Questions present, deliverables incomplete -> questions take priority.
|
|
return True
|
|
|
|
# Package complete: superseded iff every deliverable is strictly newer than
|
|
# the questions file. Any mtime error on a proven-existing file -> active.
|
|
try:
|
|
q_mtime = os.path.getmtime(questions_path)
|
|
base = _work_item_dir(worktree_path, work_item_id)
|
|
for name in DELIVERABLES:
|
|
dp = os.path.join(base, name)
|
|
if not os.path.isfile(dp) or not (os.path.getmtime(dp) > q_mtime):
|
|
# A deliverable is missing or not strictly newer -> NOT superseded
|
|
# -> questions still active (Needs Input). (files_ok True means the
|
|
# gate saw all 4; a missing file here is defensive only.)
|
|
return True
|
|
# All 4 deliverables strictly newer -> superseded -> In Review.
|
|
return False
|
|
except Exception as e: # noqa: BLE001 - mtime error on existing file -> active
|
|
logger.warning(
|
|
"questions_active: freshness check failed for %s -> active (Needs Input): %s",
|
|
work_item_id, e,
|
|
)
|
|
return True
|