Files
orchestrator/src/analyst_questions.py
claude-bot d6b495f156
All checks were successful
CI / test (push) Successful in 1m14s
CI / test (pull_request) Successful in 1m11s
fix(analysis): activate analyst open-questions -> Needs Input flow (ORCH-120)
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>
2026-06-17 13:15:27 +03:00

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