Compare commits

...

54 Commits

Author SHA1 Message Date
post-deploy-monitor
aa1f8c3088 docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-095
All checks were successful
CI / test (push) Successful in 41s
2026-06-10 02:59:41 +03:00
deploy-finalizer
2686e3e99f deploy(ORCH-036): finalize SUCCESS for ORCH-095
All checks were successful
CI / test (push) Successful in 40s
2026-06-10 00:21:48 +03:00
cdc5e5c548 tester(ET): auto-commit from tester run_id=530
All checks were successful
CI / test (push) Successful in 41s
CI / test (pull_request) Successful in 41s
2026-06-10 00:17:26 +03:00
b77d412c36 reviewer(ET): auto-commit from reviewer run_id=529 2026-06-10 00:17:26 +03:00
b38cc16041 fix(notifications): escape all card data fields at the render boundary (ORCH-095)
render_task_tracker sends/edits the live card with parse_mode=HTML. _fmt_minutes
returns the literal "<1м" for a sub-minute stage; interpolated raw into HTML text
Telegram parsed "<1м" as an opening tag -> editMessageText 400 can't parse
entities -> edit_telegram EDIT_FAILED -> update_task_tracker early return
(anti-duplicate ORCH-087) -> the card froze (incident ORCH-093, message_id 18854).

Close the whole "unescaped data in HTML text" class per ADR-001: a module-local
_esc(x)=html.escape(str(x)) (never-raise) wraps every DATA slot (durations, status
label, model, effort, token/cost metrics) exactly once at the render boundary in
render_task_tracker/_stage_line. Source functions stay HTML-agnostic (_fmt_minutes
still returns "<1м"; escape on the boundary renders it visually identical as
&lt;1м, so the visible format is unchanged). Intentional MARKUP slots (num_html /
link_for / _done_link / already-escaped esc_title) are NOT escaped, so the issue
number stays a clickable <a> tag and nothing is double-escaped.

A previously-frozen card auto-recovers on the next stage transition (a new safe
render edits in place, 200) — no new code, no touch to edit_telegram /
update_task_tracker / the orphan ledger, so the ORCH-087 anti-duplicate invariant
is preserved (a transient edit failure still does not spawn a new card).

STAGE_TRANSITIONS / QG_CHECKS / check_* / notification transport / DB schema are
untouched. New tests/test_tracker_html_escape.py (TC-01..TC-11); full suite green.

Refs: ORCH-095

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:17:26 +03:00
6b14b07f40 architect(ET): auto-commit from architect run_id=526 2026-06-10 00:17:26 +03:00
d528f77b03 analyst(ET): auto-commit from analyst run_id=525 2026-06-10 00:17:26 +03:00
c8aab19958 docs: init ORCH-095 business request 2026-06-10 00:17:26 +03:00
af86c7fabb Merge pull request 'docs(ORCH-095): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)' (#108) from docs/ORCH-095-staging-log into main 2026-06-10 00:17:00 +03:00
7fa381d814 docs(ORCH-095): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
All checks were successful
CI / test (pull_request) Successful in 42s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:16:47 +03:00
e0f44cc4ef Merge pull request 'fix(deploy): terminal-window-aware guard so done tasks hold Done in Plane (ORCH-094)' (#105) from feature/ORCH-094-bug-done-deploy-plane-awaiting into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 23:45:49 +03:00
deploy-finalizer
b243343cd5 deploy(ORCH-036): finalize SUCCESS for ORCH-094
All checks were successful
CI / test (push) Successful in 43s
CI / test (pull_request) Successful in 41s
2026-06-09 23:45:46 +03:00
fe35b2224a tester(ET): auto-commit from tester run_id=523
All checks were successful
CI / test (push) Successful in 42s
CI / test (pull_request) Successful in 40s
2026-06-09 23:41:24 +03:00
08ca4ab258 reviewer(ET): auto-commit from reviewer run_id=522 2026-06-09 23:41:24 +03:00
a46dcbcab3 fix(deploy): terminal-window-aware guard so done tasks hold Done in Plane (ORCH-094)
A DB stage=done task with 0 active jobs flapped in Plane between `Awaiting
Deploy` and `Monitoring after Deploy` instead of holding `Done` (verified live
on ORCH-061, task 47): the three deploy-phase setters were terminal-blind, so
any stale/duplicate/unknown caller under the bot token re-stamped an
intermediate status over the terminal Done, forever.

- New leaf src/deploy_status_guard.py (pure, never-raise, config-gated): decide()
  -> ALLOW | CONVERGE_DONE | SUPPRESS on the entry of set_issue_awaiting_deploy /
  set_issue_deploying / set_issue_monitoring. A deploy-phase status is legitimate
  iff the task is non-terminal OR (done AND post-deploy window active); otherwise
  done converges to Done idempotently, cancelled is suppressed (FR-2, D1/D2).
- D3: move post_deploy.arm_monitor ABOVE the terminal-sync block in advance_stage
  so window_active is True when the legitimate first Monitoring is set (the task
  is already DB-done by then); a re-drive after the window closes converges to Done.
- D4: run_post_deploy_monitor no-ops without a status PATCH / re-queue when the
  task became cancelled mid-window (zombie-tick guard, FR-3).
- D5: additive `reason` kwarg on the three setters + one structured log line per
  verdict (work_item/caller/target/db_stage/window_active/verdict); new read-only
  db.get_task_by_work_item_id; post_deploy.window_active helper.
- Flags deploy_status_guard_enabled (kill-switch -> 1:1) / deploy_status_guard_repos
  (CSV; empty = self-hosting only). STAGE_TRANSITIONS / QG_CHECKS / check_* /
  machine-verdict keys / DB schema untouched (reads existing tasks.stage).

Tests: TC-01..TC-12 across 5 new test modules + config flags; updated the
reason-kwarg assertions in test_deploy_terminal_sync / test_deploy_approve.
Full regress green (1413). Docs: CHANGELOG, CLAUDE.md, docs/architecture/README.md
(status -> реализовано), .env.example.

Refs: ORCH-094

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:41:24 +03:00
db4dd275e4 architect(ET): auto-commit from architect run_id=520 2026-06-09 23:41:24 +03:00
8959e0e3f4 architect(ET): auto-commit from architect run_id=519 2026-06-09 23:41:24 +03:00
f36528705e analyst(ET): auto-commit from analyst run_id=518 2026-06-09 23:41:24 +03:00
5e01df00eb docs: init ORCH-094 business request 2026-06-09 23:41:24 +03:00
fcb40eb4bb Merge pull request 'docs(ORCH-094): staging gate log — SUCCESS' (#106) from docs/ORCH-094-staging-log into main 2026-06-09 23:40:59 +03:00
b86fc9043f docs(ORCH-094): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
All checks were successful
CI / test (pull_request) Successful in 41s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:40:46 +03:00
fbedd0485b Merge pull request 'fix(merge_gate): retry transient Gitea merge errors (405/5xx) + already-in-main guard (ORCH-093)' (#104) from feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 22:51:43 +03:00
deploy-finalizer
f9ce5ca1b8 deploy(ORCH-036): finalize SUCCESS for ORCH-093
All checks were successful
CI / test (push) Successful in 38s
CI / test (pull_request) Successful in 40s
2026-06-09 22:51:42 +03:00
7863932012 tester(ET): auto-commit from tester run_id=516
All checks were successful
CI / test (push) Successful in 42s
CI / test (pull_request) Successful in 39s
2026-06-09 22:47:20 +03:00
74418893d7 reviewer(ET): auto-commit from reviewer run_id=515 2026-06-09 22:47:20 +03:00
0b25fc1527 fix(merge_gate): retry transient Gitea merge errors + already-in-main guard
merge_pr now wraps ONLY the mutating POST /pulls/{n}/merge in a bounded
exponential-backoff retry-loop on TRANSIENT outcomes (405 "try again later",
408, any 5xx, network/timeout, and 409|422 while the PR is still mergeable);
TERMINAL outcomes (403/404/real conflict via mergeable==False) -> fast honest
False, so the ORCH-071/081 not-merged HOLD backstop is unchanged. Fixes the
ORCH-063 false HOLD + manual re-merge on Gitea's post-push mergeability hiccup.

ensure_open_pr gains an "already fully in main" guard (_branch_fully_in_main,
git merge-base --is-ancestor HEAD origin/main) BEFORE creating a PR -> new
"already-in-main" outcome avoids the garbage empty PR on a re-driven finalizer;
_handle_merge_verify skips merge_pr on that outcome and lets the authoritative
SHA-in-main check confirm -> done (not a HOLD). git error of the guard fails
OPEN to the create path.

New ORCH_MERGE_RETRY_* settings (kill-switch merge_retry_enabled -> one-shot,
max_attempts=3, backoff base=2/max=5). INV-4 (merge only via Gitea PR-merge API,
never push/force-push main), never-raise, STAGE_TRANSITIONS/QG_CHECKS/DB schema
unchanged. Docs (README merge-verify section, CLAUDE.md, CHANGELOG, .env.example)
updated in the same PR. Tests: test_merge_gate.py TC-01..12, test_config.py
TC-13, test_merge_verify.py TC-14..16; full suite green (1389).

Refs: ORCH-093

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:47:20 +03:00
3d0f51512b architect(ET): auto-commit from architect run_id=512 2026-06-09 22:47:20 +03:00
520373a694 analyst(ET): auto-commit from analyst run_id=511 2026-06-09 22:47:20 +03:00
cf0a72a46b docs: init ORCH-093 business request 2026-06-09 22:47:20 +03:00
1a52fcba9e docs(ORCH-093): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:46:56 +03:00
33b7fd57ff Merge pull request 'fix(notifications): tracker card — status completeness, rollback reflection, stage-metric summation (ORCH-091)' (#102) from feature/ORCH-091-bug-to-analyse-stage-deploy-st into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 22:13:09 +03:00
deploy-finalizer
6feae55a4b deploy(ORCH-036): finalize SUCCESS for ORCH-091
All checks were successful
CI / test (push) Successful in 32s
2026-06-09 22:13:08 +03:00
86b013c872 tester(ET): auto-commit from tester run_id=509
All checks were successful
CI / test (push) Successful in 36s
CI / test (pull_request) Successful in 33s
2026-06-09 22:08:52 +03:00
3d6e957cae reviewer(ET): auto-commit from reviewer run_id=508 2026-06-09 22:08:52 +03:00
328ae78da3 fix(notifications): tracker card — status-map completeness, rollback reflection, stage-metric summation (ORCH-091)
Three verified live-card defects in src/notifications.py (ORCH-067/087),
all additive and indication-only (STAGE_TRANSITIONS / QG_CHECKS / check_* /
transport / DB schema untouched; never-raise; revert = git revert):

- Деф.1 (D1): _STAGE_STATUS_LABEL covered 8 of 10 STAGE_TRANSITIONS keys —
  deploy-staging and cancelled (ORCH-090) fell back to the misleading
  "To Analyse". Added deploy-staging→"Deploying (staging)",
  cancelled→"Cancelled"; replaced the runtime fallback for an UNMAPPED stage
  with a neutral capitalized label (_neutral_stage_label). created stays an
  explicit "To Analyse"; broken/None input degrades safely. Map completeness
  is asserted programmatically from STAGE_TRANSITIONS.keys() (single source of
  truth), not a static list.
- Деф.2 (D2): the stage-row loop drew  for any stage with a finished agent
  run regardless of position — after a rollback the card showed the absurd
  " Внедрение + 🔄 Разработка". Added read-only _pipeline_pos from the
  STAGE_TRANSITIONS order and a suppression gate ( only when
  current_pos >= _pipeline_pos(stage_key)); deploy-staging→deploy normalization
  applied ONLY to the current position; is_active_stage untouched.
- Деф.3 (D3): _stage_line took only the LAST run (ORCH-069: developer 3 runs
  Σ $3.98 rendered ~$0.00). It now aggregates ALL of the agent's runs with the
  same per-run formulas as the task totals → strict convergence with
  SUM(agent_runs) by task_id; model/effort/attempt come from the last run.

Tests: test_tracker_status_line.py (ORCH-091 TC-01..TC-03 + updated tc06);
new test_tracker_rollback_metrics.py (TC-05..TC-08). Full suite green (1370).
Docs: CHANGELOG + internals.md (architecture README already updated by architect).

Refs: ORCH-091
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:08:52 +03:00
c0f2d917bf architect(ET): auto-commit from architect run_id=506 2026-06-09 22:08:52 +03:00
53022d20f4 architect(ET): auto-commit from architect run_id=505 2026-06-09 22:08:52 +03:00
852da919b9 analyst(ET): auto-commit from analyst run_id=504 2026-06-09 22:08:52 +03:00
67f7a3abfa docs: init ORCH-091 business request 2026-06-09 22:08:52 +03:00
a994b25146 Merge pull request 'docs(ORCH-091): staging gate log — SUCCESS' (#103) from docs/ORCH-091-staging-log into main 2026-06-09 22:08:27 +03:00
36cd6e887b docs(ORCH-091): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
All checks were successful
CI / test (pull_request) Successful in 34s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:08:14 +03:00
3b64cddd32 Merge pull request 'feat(cancel): STOP-status task cancellation + relaunch-hole close (ORCH-090)' (#101) from feature/ORCH-090-stop-plane into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 21:36:12 +03:00
deploy-finalizer
08e6bfc3d5 deploy(ORCH-036): finalize SUCCESS for ORCH-090
All checks were successful
CI / test (push) Successful in 34s
2026-06-09 21:36:11 +03:00
5ca9b8fd62 tester(ET): auto-commit from tester run_id=502
All checks were successful
CI / test (push) Successful in 36s
CI / test (pull_request) Successful in 31s
2026-06-09 21:31:56 +03:00
07190f69f5 reviewer(ET): auto-commit from reviewer run_id=501 2026-06-09 21:31:56 +03:00
aae65969d5 fix(cancel): narrow STOP critical-window so deploy-park cancel applies (ORCH-090)
Review P1: a STOP while a self-hosting task is PARKED on `deploy` awaiting the
manual `Confirm Deploy` was classified as a critical merge/deploy window solely
because the task still held the per-repo merge-lease (held from merge-gate through
deploy->done). That window is fully reversible — nothing is merged or deployed yet
(the irreversible merge_pr runs later in _handle_merge_verify, always under an
INITIATED marker). So the cancel was DEFERRED to run_deploy_finalizer, which only
runs after Phase B (Confirm Deploy) — the very step the operator pressed STOP to
avoid. Result: the deferred cancel was never applied, the task wedged non-terminal
holding the lease, blocking the repo's serial-gate (ORCH-088) and merges.

Fix: gate the merge-lease branch of cancel.in_critical_window on an actively
RUNNING actor (_task_has_running_actor). Lease held + running deploy/merge job ->
still deferred (genuine in-flight step). Lease held + no running actor (idle
deploy parking) -> NOT critical -> immediate full reset, which itself releases the
lease (step 3c) and drives the task terminal. INITIATED-marker deferral unchanged.

Also fixes review P2 (AC-6): set_task_cancel_requested now returns the first-stamp
fact (rowcount), and the deferred branch only notifies on the first transition —
a repeated STOP while still deferred no longer spams duplicate notifications.

Tests: test_d7_lease_held_idle_parking_is_not_critical,
test_d7_lease_held_with_running_actor_still_critical,
test_d7_stop_on_deploy_awaiting_confirm_full_resets,
test_d7_repeated_stop_in_critical_window_no_duplicate_notify. Full suite green (1349).

Refs: ORCH-090

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:31:56 +03:00
46c59bad99 reviewer(ET): auto-commit from reviewer run_id=499 2026-06-09 21:31:56 +03:00
ebbf2e7a2d feat(cancel): STOP-status task cancellation + relaunch-hole close (ORCH-090)
Introduce the dedicated Plane STOP status as a single declarative task-cancel
mechanism: stop the active agent (graceful SIGTERM cascade), cancel all jobs
(terminal `cancelled`, never requeued), remove the worktree + delete the remote
feature branch (never main, never force-push), drive the task to the new
system-terminal state `cancelled` and tombstone the natural keys so a later
"To Analyse" re-creates it from scratch (docs artefacts preserved). STOP during a
critical merge/deploy window is deferred until the irreversible step finishes
honestly. Also closes the relaunch hole: handle_status_start relaunch is gated to
the `analysis` stage; the only pipeline-start entry point remains "To Analyse".

Cross-cutting (adr-0026): the "task terminal" predicate is widened {done} ->
{done, cancelled} in serial_gate / task_deps / stages sink + reaper/worker
requeue guards. STAGE_TRANSITIONS exit-gates / QG_CHECKS / check_* are unchanged
(`cancelled` is a sink, not a new edge). Additive, never-raise, restart-safe,
under kill-switch ORCH_STOP_STATUS_ENABLED (off -> zero regression).

New: src/cancel.py (leaf), src/gitea.py (delete_remote_branch), tasks columns
cancelled_at/cancel_requested_at, jobs status `cancelled`, GET /queue `stop` block.
Tests: tests/test_stop_status.py (TC-01..TC-14 + D7); full suite green (1345).
Docs updated in-PR (architecture README, CLAUDE.md, README.md, .env.example,
CHANGELOG). ADR-001 D4 refinement: plane_issue_id is tombstoned too (the lookup
ORs on it) — original UUID recoverable from the parseable suffix.

Refs: ORCH-090

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:31:56 +03:00
ab083ba826 architect(ET): auto-commit from architect run_id=497 2026-06-09 21:31:56 +03:00
96a99a09b7 analyst(ET): auto-commit from analyst run_id=496 2026-06-09 21:31:56 +03:00
105d6e9cba docs: init ORCH-090 business request 2026-06-09 21:31:56 +03:00
7b760e54da docs(ORCH-090): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:31:30 +03:00
6ae611a376 Merge pull request 'ORCH-062 — INFRA: авто-prune docker build cache на mva154' (#100) from feature/ORCH-062-infra-prune-docker-build-cache into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-09 19:59:13 +03:00
deploy-finalizer
c816b33c19 deploy(ORCH-036): finalize SUCCESS for ORCH-062
All checks were successful
CI / test (push) Successful in 29s
2026-06-09 19:59:13 +03:00
107 changed files with 9791 additions and 109 deletions

View File

@@ -121,6 +121,35 @@ ORCH_TASK_DEPS_SOURCE=db
ORCH_SERIAL_GATE_ENABLED=true ORCH_SERIAL_GATE_ENABLED=true
ORCH_SERIAL_GATE_REPOS= ORCH_SERIAL_GATE_REPOS=
ORCH_SERIAL_GATE_FREEZE_ENABLED=true ORCH_SERIAL_GATE_FREEZE_ENABLED=true
# ORCH-090: STOP-status task cancellation (stop active agent + full progress reset)
# and the relaunch-hole close. A dedicated Plane "STOP" status (logical key `stop`,
# fail-closed: absent from _DEFAULT_STATES, so a board without the status -> no-op)
# routes to a cancel handler that drives the task to the system-terminal state
# `cancelled` (stop agent via the graceful SIGTERM cascade, cancel all jobs, remove
# worktree + delete the remote feature branch [never main / never force-push],
# tombstone the natural keys for a clean re-create via "To Analyse"; docs preserved).
# STOP during a critical merge/deploy window is DEFERRED until the irreversible step
# finishes honestly. The relaunch-hole gate restricts the "To Analyse" agent relaunch
# to the `analysis` stage (the sole Needs-Input owner). Additive, never-raise.
# Infra precondition: create a "STOP" status with the `cancelled` group on the ORCH
# board (07-infra-requirements.md). Leaf src/cancel.py.
# STOP_STATUS_ENABLED=false -> STOP handling AND the relaunch-hole gate are inert
# (behaviour strictly as before ORCH-090).
# STOP_STATUS_REPOS (CSV) -> scope; EMPTY = ALL repos (cancellation is meaningful
# for enduro too).
ORCH_STOP_STATUS_ENABLED=true
ORCH_STOP_STATUS_REPOS=
# ORCH-094: terminal-window-aware guard for the three deploy-phase Plane status
# setters (set_issue_awaiting_deploy / set_issue_deploying / set_issue_monitoring).
# A DB stage=done task converges to Done idempotently instead of flapping
# Awaiting <-> Monitoring, EXCEPT the legitimate post-deploy Monitoring while the
# window is active (ARMED & not DONE). Leaf src/deploy_status_guard.py, never-raise;
# STAGE_TRANSITIONS / QG_CHECKS / machine-verdict keys untouched (no DB migration).
# DEPLOY_STATUS_GUARD_ENABLED=false -> setters are terminal-blind (1:1 pre-ORCH-094).
# DEPLOY_STATUS_GUARD_REPOS (CSV) -> scope; EMPTY = self-hosting only (orchestrator),
# the only repo where deploy-phase statuses are set.
ORCH_DEPLOY_STATUS_GUARD_ENABLED=true
ORCH_DEPLOY_STATUS_GUARD_REPOS=
# ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in # ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in
# advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic # advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic
# merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/ # merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/
@@ -148,6 +177,22 @@ ORCH_MERGE_PR_TIMEOUT_S=60
ORCH_MERGE_VERIFY_TIMEOUT_S=60 ORCH_MERGE_VERIFY_TIMEOUT_S=60
ORCH_REGRESSION_GUARD_ENABLED=true ORCH_REGRESSION_GUARD_ENABLED=true
ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true
# ORCH-093: deterministic merge-actor retry of TRANSIENT Gitea merge errors. merge_pr
# wraps ONLY the mutating POST /pulls/{n}/merge in a bounded exponential-backoff
# retry-loop on transient outcomes (405 "try again later" / 408 / 5xx / network /
# timeout, and 409|422 while the PR is still mergeable); terminal outcomes
# (403/404/real conflict) -> fast honest False (the ORCH-071/081 HOLD backstop is
# unchanged). Fixes the ORCH-063 false HOLD + manual re-merge. The already-in-main
# guard (no commits beyond origin/main -> no garbage PR) is always-on under
# MERGE_VERIFY_AUTOCREATE_PR_ENABLED (no separate flag).
# MERGE_RETRY_ENABLED -> kill-switch; false -> exactly one POST (one-shot, prior behaviour).
# MERGE_RETRY_MAX_ATTEMPTS -> max POST attempts on a transient outcome.
# MERGE_RETRY_BACKOFF_BASE_S -> exponential backoff base seconds (sleep = base*2^(i-1)).
# MERGE_RETRY_BACKOFF_MAX_S -> per-sleep backoff ceiling seconds (bounds total wait).
ORCH_MERGE_RETRY_ENABLED=true
ORCH_MERGE_RETRY_MAX_ATTEMPTS=3
ORCH_MERGE_RETRY_BACKOFF_BASE_S=2
ORCH_MERGE_RETRY_BACKOFF_MAX_S=5
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo # ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook; # (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three # deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three

View File

@@ -1,4 +1,4 @@
Work item: ORCH-088 Work item: ORCH-093
Repo: orchestrator Repo: orchestrator
Branch: feature/ORCH-088-orch-88-10-20 Branch: feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p
Stage: development Stage: development

View File

@@ -3,6 +3,43 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased] ## [Unreleased]
- **Live-карточка трекера: HTML-инъекция «<1м» больше не застывает карточку — экранирование всех данных-полей на границе рендера** (ORCH-095, `fix`): карточка задачи (`src/notifications.py::render_task_tracker`) шлётся/редактируется с `parse_mode=HTML`. `_fmt_minutes` для стадии < 60 с возвращает литерал `"<1м"`, который интерполировался в HTML-текст **сырым** → Telegram парсит `<1м` как открывающий тег → `editMessageText` отвечает `400 can't parse entities: Unsupported start tag "1м"``edit_telegram` классифицирует как `EDIT_FAILED``update_task_tracker` делает ранний `return` (анти-дубль ORCH-087) → **карточка застывает** (детерминированно воспроизведено 09.06 на ORCH-093, `message_id 18854`). Корневой класс шире одного `<1м`: все подставляемые **данные** (длительности, статус-лейбл, модель, эффорт, токены/стоимость) вставлялись сырыми; экранирован был только заголовок (`esc_title`) и href/label внутри `plane_issue_link`. **Аддитивно, never-raise, без нового поведения конвейера:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / транспорт нотификаций / схема БД — **не тронуты** (затронут ровно один модуль индикативного слоя); kill-switch не требуется (исправление дефекта корректности, откат = `git revert`).
- **Экранирование на границе рендера, не в источнике (ADR-001 D1/D2, AC-1/AC-2):** новый модуль-локальный хелпер `_esc(x) = html.escape(str(x))` (never-raise → `""` на исключении) оборачивает каждое подставляемое **данные-значение** (категория D) ровно один раз в точке интерполяции в `render_task_tracker`/`_stage_line`: длительности (`_fmt_minutes`/`_capped_review_str`), статус-лейбл (`_card_status_label`), модель (`short_model_name`), эффорт (`_run_effort`), токены/стоимость (`fmt_tokens`/`fmt_cost`). Функции-источники остаются **HTML-агностичными** (данные, не разметка): `src/usage.py` и `_fmt_minutes` не тронуты — `_fmt_minutes` продолжает возвращать `"<1м"`, безопасность даёт escape на границе (`&lt;1м` рендерится оператору визуально идентично `<1м` → видимый формат не меняется).
- **Категория M (намеренная разметка) неприкосновенна (D5, AC-3):** кликабельный номер задачи `num_html` (`plane_issue_link`, внутри уже экранированы href+label), `link_for(...)` в строке «⏳ ждёт …», `_done_link(...)` («🔗 PR #n · 📦 Внедрено») и уже-экранированный `esc_title` через `_esc` **не** проходят → остаются валидным HTML, номер остаётся кликабельным. Двойное экранирование (`&amp;lt;`) структурно исключено: D-слот → `_esc` ровно один раз, M-слот → as-is.
- **Defence-in-depth (D3):** экранируются и сейчас-безопасные D-поля (токены/стоимость/модель дают только цифры/`.`/`k`/`M`/`$`/`^claude-…$`) — escape для них no-op, выгода — структурный инвариант «каждый D-слот экранирован», устойчивый к будущей смене формата источника.
- **Восстановление застрявших карточек (D4, AC-4):** механизм — достаточное условие FR-4 без нового кода: на ближайшем переходе стадии `update_task_tracker` рендерит новый безопасный текст → `edit_telegram` отвечает `200` → застрявшая карточка обновляется на месте. Переклассификация `can't parse entities` → переотправка **отвергнута** (после фикса источник из наших данных устранён структурно; касание ветки `EDIT_FAILED`/леджера рискует анти-дублем ORCH-087). Known-limitation (унаследовано ORCH-087/Telegram-48ч): карточка задачи, завершившейся до деплоя фикса, не восстанавливается (нет будущего рендера).
- **Трассировка:** перед правкой блоков, помеченных ORCH-042/067/087/091, прочитаны их ADR — инварианты (одна карточка на задачу, леджер сирот + анти-дубль, отражение откатов + суммирование `_stage_line`, строка Plane-статуса/кликабельный номер) сохранены по построению (ORCH-095 лишь оборачивает уже вычисленные D-значения в `_esc`, не меняя состав строк/порядок/логику подавления).
- Тесты: новый `tests/test_tracker_html_escape.py` (TC-01..TC-11: sub-minute escape на границе, never-raise `_fmt_minutes`/`_esc` на граничных входах, рендер sub-minute без сырого `<1м`, заголовок со спецсимволами без двойного экранирования, escape статус-лейбла/модели/эффорта, HTML-безопасность токенов/стоимости, регресс кликабельного `<a href>` номера и `_done_link`, parse-safe edit-payload, edit-in-place без новой карточки + анти-дубль на транзиентном фейле, never-raise на битых входах). Полный регресс `tests/ -q` зелёный (1437). ADR: `docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md`. Откат: `git revert` (один модуль + тесты + CHANGELOG, без миграций/kill-switch).
- **Терминальная (done) задача держит `Done` в Plane: terminal-window-aware гард deploy-статусов** (ORCH-094, `fix`): задача с БД `stage=done` и 0 активных job'ов (верифицировано на ORCH-061, task 47) стабильно флаппила в Plane `Awaiting Deploy ⟷ Monitoring after Deploy` (273 активности парами, само не затихает) вместо `Done`. Корень: три deploy-фазовых сеттера (`set_issue_awaiting_deploy`/`set_issue_deploying`/`set_issue_monitoring`) **терминал-слепы** — любой стейл/двойной/неизвестный вызов под бот-токеном перезаписывает `Done` промежуточным deploy-статусом, и обратно, бесконечно. **Аддитивно, never-raise, под kill-switch, в зоне self-hosting:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи (`deploy_status:`/`staging_status:`/…) / схема БД — **не тронуты** (читается существующая `tasks.stage`, без миграции).
- **Единый гард на низком чокпоинте (FR-2, D1/D2):** новый leaf `src/deploy_status_guard.py` (чистая, never-raise, config-gated логика; по образцу `serial_gate.py`/`labels.py`/`cancel.py`) — `decide(work_item_id, target, reason) -> ALLOW | CONVERGE_DONE | SUPPRESS`. Гард ставится на **входе** трёх сеттеров `plane_sync` (а не в caller'ах `stage_engine`) → перехватывает **любой** путь, включая неизвестный актор под бот-токеном. Предикат легитимности: deploy-статус легитимен ⇔ задача **нетерминальна** ИЛИ (`done` **И** активно пост-деплой-окно `post_deploy.window_active` = ARMED & не DONE). Для `done`: `monitoring`+окно-активно → `ALLOW`; иначе → `CONVERGE_DONE` (сеттер вместо PATCH'а зовёт `set_issue_done`, идемпотентно). `cancelled``SUPPRESS` (не штампуем поверх терминала ORCH-090). Нетерминальная задача → `ALLOW` (рабочий deploy-цикл 1:1, AC-4). Task не найден / не-self репо / kill-switch off / любое исключение → `ALLOW` (fail-safe к прежнему поведению 1:1, NFR-1).
- **Перенос арм-блока перед terminal-sync (D3, AC-4):** в `advance_stage` (ветка `next_stage=="done"`) блок `post_deploy.arm_monitor` перемещён **выше** блока `set_issue_monitoring` (стр. 404). Критично: `update_task_stage(task_id,"done")` пишет `stage='done'` **раньше** легитимного первого `Monitoring` — без переноса гард ошибочно свёл бы его к Done. Арм-первым пишет `ARMED``window_active==True``ALLOW` пропускает легитимный `Monitoring`; re-drive `deploy→done` **после** закрытия окна (`DONE` present) → `window_active==False``CONVERGE_DONE` (не воскрешает `Monitoring`). Перенос безопасен: `arm_monitor` лишь пишет sentinel + ставит отложенный job, не зависит от Plane-статуса/merge-lease (release остаётся после terminal-sync). Инварианты ORCH-021 (идемпотентный арм по `ARMED`) и ORCH-066 (`deploy→done` self ⇒ `Monitoring`) сохранены.
- **Харднинг пост-деплой-монитора (FR-3, D4, AC-3):** `run_post_deploy_monitor` — существующий идемпотентный страж `has_marker(DONE)` (no-op завершённого окна) сохранён; аддитивно: тик при БД `stage='cancelled'` мид-окно → закрыть окно `mark_done` **без статус-PATCH и без перепостановки** следующего тика (zombie-tick guard). Перепостановка остаётся строго при `HEALTHY and ticks < budget` (тик ≡ job; нет job → нет тика). После закрытия окна — 0 последующих статус-PATCH; любой стейл `set_issue_monitoring` добивается гардом D2.
- **Наблюдаемость (FR-4, D5, AC-5):** аддитивный BC-kwarg `reason: str | None = None` у трёх сеттеров; call-site'ы передают `"advance:deploy->done"`/`"phase_a"`/`"phase_b"`. `decide` эмитит ОДНУ структурную запись на вызов: `work_item`, `caller(reason)`, `target_status`, `db_stage`, `window_active`, `verdict` (`ALLOW` → INFO; `CONVERGE_DONE`/`SUPPRESS` → WARNING, «что подавили и почему» — атрибуция будущего флаппа). Новый read-only аксессор `db.get_task_by_work_item_id` (human-readable `work_item_id` матчит живой ряд; тумбстоны ORCH-090 имеют суффикс `#cancelled-<id>`).
- **Конфиг/откат (FR-5, D6):** `src/config.py` `deploy_status_guard_enabled: bool = True` (env `ORCH_DEPLOY_STATUS_GUARD_ENABLED`; `False` → сеттеры терминал-слепы, поведение **1:1** прежнее) / `deploy_status_guard_repos: str = ""` (env `ORCH_DEPLOY_STATUS_GUARD_REPOS`; CSV, **пусто → self-hosting only** — не-self репо (enduro) гард не трогает, нулевая регрессия). Откат: `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` (мгновенный runtime) или revert ветки.
- **Источник флаппа (BR-7):** code-писатели deploy-статусов — только `stage_engine.py:404/1218/1316`; реконсилятор F-2 эти статусы не перебирает; live-overlay `notifications.py` — read-only. Гард — **буфер на стороне орка**, гасящий маятник за один цикл независимо от актора (известный/стейл/неизвестный под бот-токеном). Если актор — внешняя Plane-automation под другим токеном, code-фикс не закрывает её полностью, но идемпотентное схождение к Done нейтрализует видимый эффект.
- **Трассировка:** перед правкой блока `next_stage=="done"` (маркеры ORCH-021/066/043/088) прочитаны их ADR — инварианты сохранены (deploy→done self ⇒ Monitoring; монитор-close ⇒ Done; терминал-набор `{done,cancelled}`). Тесты: `tests/test_deploy_status_terminal_guard.py` (TC-01..05/12), `tests/test_post_deploy_monitor_termination.py` (TC-06..08), `tests/test_deploy_status_observability.py` (TC-09), `tests/test_reconciler_done_deploy_convergence.py` (TC-10), `tests/test_self_deploy_cycle_regression.py` (TC-11). Обновлены анти-регресс-ассерты `tests/test_deploy_terminal_sync.py`/`test_deploy_approve.py` под `reason`-kwarg. Полный регресс `tests/ -q` зелёный (1411). ADR: `docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`, сквозной `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`.
- **Merge-актор ретраит транзиентные ошибки Gitea (405/5xx) + гард «ветка уже в `main`»** (ORCH-093, `fix`): две точечные доработки детерминированного merge-актора `src/merge_gate.py`, чинящие инцидент **ORCH-063**: self-deploy прошёл, staging OK, PR был `open`+`mergeable`, но `POST /pulls/{n}/merge` вернул `HTTP 405 "Please try again later"` (Gitea пересчитывал `mergeable` сразу после пуша) → one-shot `merge_pr` мгновенно вернул `False` → корректная защита ORCH-071/081 удержала задачу на `deploy` + потребовала ручной домерж; повторный прогон финализатора плодил мусорный пустой PR. **Аддитивно, never-raise, под существующими kill-switch'ами:** `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — **не тронуты**; INV-4 (мерж только через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён 1:1.
- **Retry-loop транзиента (FR-1/FR-2, AC-1/AC-2/AC-3, D1/D2):** `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, дефолты 2/5 с → суммарный сон `(N-1)*max ≤ 10 с`, monitor-поток не подвешивается). Классификатор `_classify_merge_response`: **транзиент** (ретрай) — `405`/`408`/любой `5xx`/`httpx`-таймаут/сетевая ошибка, **и** `409`/`422` когда PR всё ещё mergeable; **терминал** (быстрый честный `False`, защита ORCH-071/081 как прежде) — `403`/`404`/реальный конфликт (`409`/`422` при `mergeable==False`). Неоднозначный `409`/`422` разрешается доп. `GET /pulls/{index}``mergeable`; дефолт-политика `mergeable==None`/недоступно → **транзиент** (fail-OPEN-в-ретрай: икота Gitea — наблюдаемый кейс, бюджет конечен, backstop сохранён). Каждая попытка логируется `attempt i/N` (образец `check_ci_green`).
- **Гард already-in-main (FR-3/FR-4, AC-4, D3/D4):** новый leaf `_branch_fully_in_main` (`git merge-base --is-ancestor HEAD origin/main` в per-branch worktree) вызывается в `ensure_open_pr` **между** «открытый code-PR не найден» и `POST …/pulls`: ветка целиком в `main` (нет коммитов `origin/main..HEAD`) → новый исход `"already-in-main"` **без создания PR** (нет мусорного пустого PR на уже влитой ветке). git-ошибка/ambiguous (`None`) → **fail-OPEN** (деградация на create-путь, НЕ ложный no-op). В `stage_engine._handle_merge_verify` исход `already-in-main` **пропускает** `merge_pr` (мержить нечего) и отдаёт авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done`; это НЕ HOLD. SHA-in-main остаётся единственным доказательством мержа (ADR-0014).
- **Конфиг/откат (FR-5, AC-5/AC-7, D5):** новые поля `src/config.py` `merge_retry_enabled` (kill-switch; `False` → ровно один POST = байт-в-байт прежнее one-shot, нулевая регрессия) / `merge_retry_max_attempts` (3) / `merge_retry_backoff_base_s` (2) / `merge_retry_backoff_max_s` (5), env `ORCH_MERGE_RETRY_*`, дескрипторы в `.env.example`. Гард already-in-main — без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). Откат: `ORCH_MERGE_RETRY_ENABLED=false` (мгновенный runtime) или revert PR.
- **Трассировка:** перед правкой `merge_pr`/`ensure_open_pr`/`_handle_merge_verify` прочитаны ADR ORCH-071/073/082 — инварианты (SHA-in-main authoritative, never-raise, idempotency-guard `pr_already_merged`, base==main фильтр code-PR) сохранены; в `MAIN_REGRESSION_MARKERS` добавлена строка `("ORCH-093", "_classify_merge_response", "src/merge_gate.py")` (append-only).
- Тесты: `tests/test_merge_gate.py` (TC-01..TC-12: 405×2→200, 5xx→200, network→200, реальный конфликт/403 терминал, ambiguous-mergeable, исчерпание ретраев, kill-switch one-shot, already-in-main без POST, create при коммитах сверх main, fail-OPEN на git-ошибке гарда, never-raise; `httpx` мокается, `time.sleep` → no-op), `tests/test_config.py` (TC-13: дефолты + env-override `ORCH_MERGE_RETRY_*`), `tests/test_merge_verify.py` (TC-14..TC-16: already-in-main пропускает `merge_pr`→done; исчерпание+SHA-not-in-main→HOLD; транзиент-успех→done). Обновлён `tests/test_orch082_ensure_pr.py` (гард запинён на create-путь — у гарда своё покрытие). Полный регресс `tests/ -q` зелёный (1389). ADR: `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`.
- **Live-карточка трекера: полнота карты статусов, отражение откатов, суммирование метрик стадии по попыткам** (ORCH-091, `fix`): три верифицированных дефекта рендера Telegram-карточки (`src/notifications.py`, ORCH-067/087). **Аддитивно, never-raise, без нового поведения конвейера:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / транспорт нотификаций / схема БД — **не тронуты** (затронут ровно один модуль индикативного слоя); kill-switch не требуется (рендер деградирует безопасно, откат = `git revert`).
- **Деф.1 — застрявший заголовок «To Analyse» (FR-1/2/3, AC-1/2/3):** `_STAGE_STATUS_LABEL` покрывал 8 из 10 ключей `STAGE_TRANSITIONS``deploy-staging` и `cancelled` (ORCH-090) выпадали в дефолт-«To Analyse» (ложный «первый статус» на стадии staging-деплоя). Карта расширена: `deploy-staging → "Deploying (staging)"` (plain-стиль активной стадии, суффикс «(staging)» снимает коллизию с prod-overlay `_LIVE_BRANCH_LABELS['deploying']` и с pause-лейблом `deploy`), `cancelled → "Cancelled"` (offline-база ORCH-090, совпадает с overlay-лейблом → нет конфликта precedence). Runtime-фолбэк `plane_status_label` для **немаппленной** (будущей/неизвестной) стадии заменён с «To Analyse» на **нейтральный** капитализированный лейбл (`_neutral_stage_label`, `"deploy-staging" → "Deploy Staging"`); `created` остаётся явным ключом → честная «To Analyse»; битый/None-вход → безопасный дефолт. Полнота карты гарантируется **программно** тестом, итерирующим `STAGE_TRANSITIONS.keys()` (единый источник истины) — новая стадия без курируемого лейбла даёт красный тест; автогенерация лейблов в самом модуле запрещена (карта остаётся курируемой/человекочитаемой).
- **Деф.2 — ложная картина при откате (FR-4, AC-4):** цикл рендера выводил `✅`-строку для каждой стадии с завершённым прогоном её агента **без учёта позиции** относительно текущей — после отката (`deploy-staging → development` ORCH-043, `review → development` REQUEST_CHANGES) карточка показывала абсурд «✅ Внедрение … + 🔄 Разработка». Введён лёгкий read-only хелпер `_pipeline_pos` от **порядка `STAGE_TRANSITIONS`** (не от `_TRACKER_STAGES`, который не содержит `deploy-staging`/`cancelled` и не авторитетен по порядку); гейт подавления: `✅`-строка рисуется только если `current_pos >= _pipeline_pos(stage_key)`. Нормализация `deploy-staging → deploy` применяется **только** к вычислению текущей позиции (схлопнутая строка «Внедрение» несёт `stage_key="deploy"`); `is_active_stage`**без изменений** (нулевой регресс активного рендера). Подавлённые откатом прогоны по-прежнему входят в тоталы задачи (намеренная семантика отката).
- **Деф.3 — занижение метрик строки стадии (FR-5, AC-5):** `_stage_line` брал ПОСЛЕДНИЙ прогон (`last_done`), теряя предыдущие попытки (верифицировано на ORCH-069: developer 3 прогона Σ $3.98 → карточка показывала ~$0.00). Теперь `_stage_line` агрегирует **ВСЕ** `agent_runs` агента стадии теми же per-run-формулами, что и блок тоталов (`Σ cost_usd`, `Σ _input_total`, `Σ output_tokens`, `Σ _duration_seconds`); модель/эффорт/«попытка N» берутся из последнего прогона (`id ASC`). Каждый агент привязан ровно к одной строке `_TRACKER_STAGES` → строгий инвариант сходимости: Σ(строк стадий) ≡ тоталы задачи ≡ `SUM(agent_runs)` по `task_id`. Формат строк/тоталов и эффорт-суффикс (ORCH-087) — байт-в-байт.
- **Совместимость/регресс (NFR-2, AC-6):** In Review (brd-clock), Awaiting Deploy (`deploy`), Done, live-overlay ветки (Needs Input / Blocked / Rejected / Cancelled / Confirm Deploy / Deploying / Monitoring), строка «Подтверждение BRD», формат строк/тоталов, эффорт-суффикс — без изменений; все существующие тесты карточки зелёные. Перед правкой кода, помеченного ORCH-067/087/090, прочитаны их ADR — инварианты (single-card, never-raise, разделение offline-ядра и live-overlay, терминал `cancelled`) сохранены.
- Тесты: `tests/test_tracker_status_line.py` (ORCH-091 TC-01..TC-03: полнота карты от `STAGE_TRANSITIONS`, staging-лейбл, нейтральный фолбэк/never-raise; обновлён `test_tc06_*` под нейтральный фолбэк), новый `tests/test_tracker_rollback_metrics.py` (TC-05..TC-08: подавление `✅` при откате + анти-регресс forward-progress/`deploy-staging`-строка; суммирование метрик developer 3 прогона ≈ $3.98; сходимость тоталов с `SUM(agent_runs)`; never-raise на NULL-таймстампах/битой стадии). Полный регресс `tests/ -q` зелёный (1370). ADR: `docs/work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md`. Откат: `git revert` (docs/code-only, один модуль, без миграций/kill-switch).
- **Отмена задачи: Plane-статус STOP (остановка агента + полный сброс) + закрытие дыры релонча** (ORCH-090, `feat`): выделенный Plane-статус **STOP** — единый декларативный механизм отмены задачи вместо ручной хирургии по БД/процессам. Вводит **новое системное терминальное состояние `cancelled`** (стадия `tasks.stage='cancelled'` + job-исход `jobs.status='cancelled'`), равноправное `done`. **Аддитивно, под kill-switch, never-raise, restart-safe:** `STAGE_TRANSITIONS` (exit-гейты рёбер) / `QG_CHECKS` / `check_*` / семантика существующих статусов — **не тронуты** (`cancelled` — терминальный сток, не новое ребро); enduro не затронут; при `stop_status_enabled=false` — нулевая регрессия.
- **Распознавание (fail-closed):** новый логический ключ `stop` в `_PLANE_NAME_TO_KEY` (`"STOP" → "stop"`), **намеренно отсутствует** в `_DEFAULT_STATES` (по образцу `confirm_deploy`/ORCH-059) → доска без статуса STOP резолвит `None` → ветка не активируется (нет `KeyError`, нет слепой отмены). `handle_issue_updated` маршрутизирует `stop``handle_stop``stage_engine.cancel_task` (проверяется ПЕРВЫМ, до to_analyse/approved/rejected).
- **Полный сброс (вне критичного окна, AC-1..AC-4):** graceful SIGTERM активного агента через переиспользуемый каскад `launcher.stop_process` (вынесен из `_watchdog`: SIGTERM → grace → SIGKILL) по `jobs.pid`; `db.cancel_jobs_for_task` (queued/running → терминальный `cancelled`, нигде не реквью'ится — `claim_next_job` берёт только `queued`); `git_worktree.remove_worktree` + новый never-raise `src/gitea.py::delete_remote_branch` (удаляет **только** feature-ветку; `main`/`master` — явный гард-отказ; без force-push); durable `stage='cancelled'` + `cancelled_at`; **тумбстон** натуральных ключей суффиксом `#cancelled-<id>`. Docs-артефакты (`01..17`) сохраняются.
- **Уточнение ADR-001 D4 (при реализации):** ADR предлагал сохранить `plane_issue_id` нетронутым, но `get_task_by_plane_id`/`create_task_atomic` матчат по `plane_id OR plane_issue_id` — нетумбстоненный `plane_issue_id` заблокировал бы clean-slate re-create (BR-3/TR-4). Поэтому `plane_issue_id` тоже тумбстонится; исходный UUID (== исходный `plane_id` во всех путях создания) парсится из детерминированного суффикса для аудита. Зафиксировано в коде/`docs/architecture/README.md`/CLAUDE.md.
- **Безопасное прерывание merge/deploy (AC-7, NFR-3):** STOP в критическом окне → **отложенная отмена** (`cancel.in_critical_window` fail-CLOSED): durable `tasks.cancel_requested_at`, снимаются только `queued`-job'ы (running-актор деплоя/мержа не трогается), алерт; детерминированный `run_deploy_finalizer` доводит необратимый шаг до честного исхода и применяет отмену (`cancel_task(force=True)`; задача, дошедшая до `done`, — честный no-op, код уже в проде). «Критическое окно» = реально начатый необратимый шаг: self-deploy `INITIATED`-sentinel (ORCH-036; детач-деплой + поздний `merge_pr` в `_handle_merge_verify` идут под тем же маркером) **либо** держание merge-lease (ORCH-043) **И** активно бегущий актор (running-job). STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс.
- **Фикс P1 (ORCH-090 review, attempt 2): deferred-cancel недостижим при STOP в ожидании `Confirm Deploy` → wedge.** Для self-hosting merge-lease держится от merge-gate (ребро `deploy-staging → deploy`) до `deploy → done`, включая всё время, пока задача **припаркована** на `deploy` в ожидании ручного `Confirm Deploy` (Phase A) — но это окно **полностью обратимо** (ничего не смержено/задеплоено; необратимый `merge_pr` идёт позже в `_handle_merge_verify` уже под `INITIATED`). Прежде голое держание lease классифицировалось как «критичное» → STOP уходил в deferred-ветку, отмену применял бы только `run_deploy_finalizer` (после Phase B), которого оператор, нажавший STOP именно чтобы НЕ деплоить, никогда не запустит → отмена **не применялась никогда**, задача застревала нетерминальной с удержанным lease, клиня serial-gate репо (ORCH-088) и мержи. Фикс: merge-lease-ветка `in_critical_window` сужена — критично, лишь когда lease держится **И** есть бегущий актор (`_task_has_running_actor`, running-job); припаркованное окно без актора → НЕ критично → немедленный полный сброс (сам отпускает lease в шаге 3c). Новые тесты `test_d7_lease_held_idle_parking_is_not_critical` / `test_d7_lease_held_with_running_actor_still_critical` / `test_d7_stop_on_deploy_awaiting_confirm_full_resets`.
- **Кросс-каттинг (adr-0026):** предикат «задача терминальна» расширен `{done}``{done, cancelled}` в `serial_gate.py` (ORCH-088: `repo_has_active_task`, claim-фрагмент, snapshot), `db.claim_next_job`/`get_unfinished_dependencies` (task_deps ORCH-026) и `stages.py`-сток — иначе отменённая задача заклинила бы очередь репо (TR-1); reconciler-терминал-скип уже знал `cancelled` (ORCH-086 D2). `job_reaper`/`queue_worker` ПЕРЕД авто-requeue сверяют терминал задачи → помечают job `cancelled`, не реквью'ят (закрыта гонка SIGTERM/reaper, TR-2).
- **Закрытие дыры релонча (AC-5, D6):** `handle_status_start` больше не релончит агента середины пайплайна при ручном переводе в промежуточный статус — relaunch ограничен стадией `analysis` (единственный владелец Needs Input, ORCH-066); единственный вход к запуску пайплайна остаётся «To Analyse» (`start_pipeline`). Под `stop_status_enabled=false` гейт инертен (1:1 как раньше).
- **Флаги/наблюдаемость:** `stop_status_enabled` (kill-switch, env `ORCH_STOP_STATUS_ENABLED`) + `stop_status_repos` (CSV, пусто → все репо); leaf `src/cancel.py` (`applies`/`in_critical_window`/`snapshot`, never-raise); read-only блок `stop` в `GET /queue`; лог + Telegram (кликабельный номер) + Plane-коммент + `update_task_tracker`. Аддитивные идемпотентные миграции (`_ensure_column` для `cancelled_at`/`cancel_requested_at`). **Инфра-предусловие:** создать статус **STOP** с группой `cancelled` на доске Plane проекта ORCH (его отсутствие = fail-safe no-op).
- Тесты: `tests/test_stop_status.py` (TC-01..TC-14 + D7-кейсы, включая 3 новых P1-кейса для окна «припаркован на `deploy`, ждёт Confirm Deploy»; SIGTERM/git/gitea замоканы — ни один тест не шлёт сигнал/не трогает сеть); обновлены анти-регресс-тесты STAGE_TRANSITIONS 5 прошлых задач (добавлен терминал-сток `cancelled`); полный регресс `tests/` зелёный (1348). Документация: `docs/architecture/README.md` (статус «реализовано» + блок `/queue` + раздел БД), `CLAUDE.md`, `README.md`, `.env.example`. ADR: `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, сквозной `docs/architecture/adr/adr-0026-stop-cancel-task.md`. Откат: `ORCH_STOP_STATUS_ENABLED=false` (аддитивные колонки/терминал-набор инертны при отсутствии отменённых задач).
- **Build-cache-pruner: авто-prune docker build cache на mva154** (ORCH-062, `feat`): новый фоновый daemon-поток `src/build_cache_pruner.py` (каркас `disk_watchdog`) — «вторая половина» disk-watchdog (ORCH-063): **watchdog сигналит — pruner убирает**. Устраняет корень инцидента 07.06.2026 (docker build cache ≈11 ГБ → диск mva154 100% → падение self-hosting-конвейера всех проектов) **автоматически, без оператора**. **Аддитивно, never-raise:** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/`_parse_*`/`src/stage_engine.py`/схема БД — **не тронуты**, новой миграции нет (состояние last-run/last-result — in-memory, best-effort). - **Build-cache-pruner: авто-prune docker build cache на mva154** (ORCH-062, `feat`): новый фоновый daemon-поток `src/build_cache_pruner.py` (каркас `disk_watchdog`) — «вторая половина» disk-watchdog (ORCH-063): **watchdog сигналит — pruner убирает**. Устраняет корень инцидента 07.06.2026 (docker build cache ≈11 ГБ → диск mva154 100% → падение self-hosting-конвейера всех проектов) **автоматически, без оператора**. **Аддитивно, never-raise:** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/`_parse_*`/`src/stage_engine.py`/схема БД — **не тронуты**, новой миграции нет (состояние last-run/last-result — in-memory, best-effort).
- **Периодическая уборка (FR-1/AC-1):** каждые `build_cache_prune_interval_s` (дефолт **21600с = 6ч**) тик выполняет **строго `docker builder prune -f --filter until=<until>`** (BuildKit GC). Анти-частота — pure-функция `decide_prune(prev_run_ts, now, interval_s)` (юнит-тестируема без потока/таймера, время инъецируется). Дефолт `until=24h` удерживает тёплый недавний кэш (BR-2/AC-2); `-a/--all` (`build_cache_prune_all`, дефолт `False`) — **только в паре** с возрастным фильтром. - **Периодическая уборка (FR-1/AC-1):** каждые `build_cache_prune_interval_s` (дефолт **21600с = 6ч**) тик выполняет **строго `docker builder prune -f --filter until=<until>`** (BuildKit GC). Анти-частота — pure-функция `decide_prune(prev_run_ts, now, interval_s)` (юнит-тестируема без потока/таймера, время инъецируется). Дефолт `until=24h` удерживает тёплый недавний кэш (BR-2/AC-2); `-a/--all` (`build_cache_prune_all`, дефолт `False`) — **только в паре** с возрастным фильтром.
- **Self-hosting безопасность (FR-3/AC-3):** команда затрагивает **только** build cache — **нет** `docker image prune`/`docker system prune`, удаления образов/контейнеров запущенных сервисов, остановки/рестарта контейнеров; прод-контейнер `orchestrator` **никогда** не рестартится. Уборка исполняется **на хосте через ssh** (`deploy_ssh_user@deploy_ssh_host`, тот же канал, что `image_freshness`/`self_deploy` — в образе нет docker CLI). Нет ssh-таргета → тик no-op (наблюдаемо в `status().last_error`). - **Self-hosting безопасность (FR-3/AC-3):** команда затрагивает **только** build cache — **нет** `docker image prune`/`docker system prune`, удаления образов/контейнеров запущенных сервисов, остановки/рестарта контейнеров; прод-контейнер `orchestrator` **никогда** не рестартится. Уборка исполняется **на хосте через ssh** (`deploy_ssh_user@deploy_ssh_host`, тот же канал, что `image_freshness`/`self_deploy` — в образе нет docker CLI). Нет ssh-таргета → тик no-op (наблюдаемо в `status().last_error`).

View File

@@ -7,7 +7,7 @@
- Backend: FastAPI + uvicorn (Python 3.12) - Backend: FastAPI + uvicorn (Python 3.12)
- БД: SQLite (`src/db.py`) - БД: SQLite (`src/db.py`)
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break). **ORCH-077 (52d, замыкает эпик 52):** тело всех 6 промптов переписано в едином **каноне Anthropic** (5 обязательных XML-секций в нормативном порядке `<context>``<task>``<deliverables>``<constraints>``<output_format>`, запреты в формате «❌ X → ✅ Y», `<thinking>` у решающих ролей), и каждый промпт **добровольно** эмитит 6-польную frontmatter-схему 52c (`work_item`/`stage`/`author_agent`/`status`/`created_at`/`model_used`) **аддитивно** — рядом с machine-verdict ключом, НЕ меняя его имя/регистр/значения (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:` — байт-в-байт). Это **docs/prompts-only** изменение: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; `frontmatter_validation_strict` остаётся `False` (enforcement НЕ включён). Промпт `cat`-ается из worktree в момент запуска → новые промпты вступают в силу на следующем worktree от `main` без прод-рестарта. Анти-регресс — структурные тесты `tests/test_agent_prompts_canon.py` + зелёный `test_agent_frontmatter_no_model.py`. **Норматив на будущее:** новые/изменённые агент-промпты следуют этому канону. Детали — `docs/architecture/adr/adr-0021-prompt-canon-anthropic.md`. **ORCH-092 (эпилог эпика 52, docs/prompts-only):** аудит 6 промптов поверх канона — копируемые frontmatter-примеры расхардкожены (`created_at: <YYYY-MM-DD>`/`model_used: <resolve ORCH-41>` + врезка «подставь `date +%F`/модель из конфига, не копируй буквально»; литерал `claude-opus-4-8` — только справка в таблице полей); добавлена секция `<escalation>` developer/reviewer/tester (после `</success_criteria>`, порядок 5 секций цел); developer лишён ручного `git rebase origin/main` (свежесть базы — инвариант движка serial-gate ORCH-088 + `auto_rebase_onto_main` под merge-lease; ручной rebase конфликтовал с запретом force-push — ADR-001 D1); tester обогащён worktree-путём + smoke `serial_gate` + покрытием каждого TC; из reviewer удалена мёртвая строка «тот же экземпляр Developer». **Языковое исключение (нормативно, ADR-001 D2):** `deployer.md` сознательно остаётся на **английском** (5 ru + 1 en) как самый safety-critical промпт — НЕ «чинить» язык вслепую; критичные self-hosting-запреты подняты в видную рамку. Verdict-ключи и канон 52d — байт-в-байт; анти-регресс`tests/test_agent_prompts_canon.py` (ORCH-092 TC-01…TC-08). Детали — `docs/work-items/ORCH-092/06-adr/ADR-001-developer-rebase-and-deployer-language.md`. - Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break). **ORCH-077 (52d, замыкает эпик 52):** тело всех 6 промптов переписано в едином **каноне Anthropic** (5 обязательных XML-секций в нормативном порядке `<context>``<task>``<deliverables>``<constraints>``<output_format>`, запреты в формате «❌ X → ✅ Y», `<thinking>` у решающих ролей), и каждый промпт **добровольно** эмитит 6-польную frontmatter-схему 52c (`work_item`/`stage`/`author_agent`/`status`/`created_at`/`model_used`) **аддитивно** — рядом с machine-verdict ключом, НЕ меняя его имя/регистр/значения (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:` — байт-в-байт). Это **docs/prompts-only** изменение: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; `frontmatter_validation_strict` остаётся `False` (enforcement НЕ включён). Промпт `cat`-ается из worktree в момент запуска → новые промпты вступают в силу на следующем worktree от `main` без прод-рестарта. Анти-регресс — структурные тесты `tests/test_agent_prompts_canon.py` + зелёный `test_agent_frontmatter_no_model.py`. **Норматив на будущее:** новые/изменённые агент-промпты следуют этому канону. Детали — `docs/architecture/adr/adr-0021-prompt-canon-anthropic.md`. **ORCH-092 (эпилог эпика 52, docs/prompts-only):** аудит 6 промптов поверх канона — копируемые frontmatter-примеры расхардкожены (`created_at: <YYYY-MM-DD>`/`model_used: <resolve ORCH-41>` + врезка «подставь `date +%F`/модель из конфига, не копируй буквально»; литерал `claude-opus-4-8` — только справка в таблице полей); добавлена секция `<escalation>` developer/reviewer/tester (после `</success_criteria>`, порядок 5 секций цел); developer лишён ручного `git rebase origin/main` (свежесть базы — инвариант движка serial-gate ORCH-088 + `auto_rebase_onto_main` под merge-lease; ручной rebase конфликтовал с запретом force-push — ADR-001 D1); tester обогащён worktree-путём + smoke `serial_gate` + покрытием каждого TC; из reviewer удалена мёртвая строка «тот же экземпляр Developer». **Языковое исключение (нормативно, ADR-001 D2):** `deployer.md` сознательно остаётся на **английском** (5 ru + 1 en) как самый safety-critical промпт — НЕ «чинить» язык вслепую; критичные self-hosting-запреты подняты в видную рамку. Verdict-ключи и канон 52d — байт-в-байт; анти-регресс`tests/test_agent_prompts_canon.py` (ORCH-092 TC-01…TC-08). Детали — `docs/work-items/ORCH-092/06-adr/ADR-001-developer-rebase-and-deployer-language.md`.
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты. - Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты. **ORCH-093 (merge-актор устойчив к икоте Gitea):** детерминированный merge-актор под-гейта `deploy → done` (`src/merge_gate.py`) ретраит **транзиентные** ошибки Gitea вместо ложного HOLD (инцидент ORCH-063: `POST …/merge``405 "try again later"` сразу после пуша). `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, потолок суммарного сна `(N-1)*max ≤ 10 с`); классификатор `_classify_merge_response`: транзиент (ретрай) — `405`/`408`/`5xx`/таймаут/сетевая + `409`/`422` при `mergeable==True` (доп. `GET /pulls/{index}`; `mergeable==None` → дефолт-транзиент, fail-OPEN-в-ретрай), терминал (быстрый честный `False`, защита ORCH-071/073 как прежде) — `403`/`404`/реальный конфликт (`mergeable==False`). Kill-switch `merge_retry_enabled=false` → ровно один POST (байт-в-байт прежнее one-shot); флаги `ORCH_MERGE_RETRY_*` (`max_attempts=3`, `backoff_base_s=2`, `backoff_max_s=5`). Гард **already-in-main** в `ensure_open_pr` (leaf `_branch_fully_in_main`, `git merge-base --is-ancestor HEAD origin/main`): ветка целиком в `main` → исход `"already-in-main"` без создания мусорного пустого PR; `_handle_merge_verify` пропускает `merge_pr` и отдаёт авторитетному SHA-в-main довести до `done` (НЕ HOLD); git-ошибка → fail-OPEN на create-путь. Без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). INV-4 (мерж только через Gitea PR-merge API, никогда push/force-push в `main`), never-raise, `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — сохранены. Детали — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`.
- Контейнеризация: Docker + Compose - Контейнеризация: Docker + Compose
- CI/CD: Gitea Actions (`.gitea/workflows/`) - CI/CD: Gitea Actions (`.gitea/workflows/`)
- Деплой: docker compose на mva154 - Деплой: docker compose на mva154
@@ -41,6 +41,8 @@ created → analysis → architecture → development → review → testing →
## Статусная модель Plane (ORCH-066) — индикация ≠ управление ## Статусная модель Plane (ORCH-066) — индикация ≠ управление
Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`. Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`.
**Terminal-window-aware гард deploy-статусов (ORCH-094).** Задача с БД `stage=done` и 0 активных job'ов стабильно держит Plane=`Done`: три deploy-фазовых сеттера (`set_issue_awaiting_deploy`/`set_issue_deploying`/`set_issue_monitoring`) были терминал-слепы и флаппили `Awaiting ⟷ Monitoring` (верифицировано на ORCH-061, task 47), т.к. любой стейл/двойной/неизвестный вызов под бот-токеном перезаписывал терминал промежуточным статусом. Новый leaf `src/deploy_status_guard.py` (чистая, never-raise, config-gated; по образцу `serial_gate`/`labels`/`cancel`) — `decide(work_item_id, target, reason) -> ALLOW | CONVERGE_DONE | SUPPRESS` на **входе** трёх сеттеров `plane_sync` (низкий чокпоинт ловит любой путь, включая неизвестный актор). Инвариант: deploy-статус легитимен ⇔ задача **нетерминальна** ИЛИ (`done` И активно пост-деплой-окно `post_deploy.window_active` = ARMED & не DONE); иначе для `done` — идемпотентное `CONVERGE_DONE` (сеттер зовёт `set_issue_done`), для `cancelled``SUPPRESS`. Чтобы легитимный первый `Monitoring` (БД уже `done` к моменту стр. 404) прошёл, арм-блок `post_deploy.arm_monitor` **перенесён выше** terminal-sync-блока в `advance_stage` (ADR-001 D3) → `window_active==True` до выставления `Monitoring`. Монитор-тик при БД `cancelled` мид-окно → закрыть окно без статус-PATCH (zombie-tick guard, FR-3). Наблюдаемость: BC-kwarg `reason` у трёх сеттеров + одна структурная лог-запись на вердикт (`work_item`/`caller`/`target`/`db_stage`/`window_active`/`verdict`; converge/suppress → WARNING). Read-only аксессор `db.get_task_by_work_item_id`. Флаги `deploy_status_guard_enabled` (kill-switch; `False` → 1:1 прежнее) / `deploy_status_guard_repos` (CSV; **пусто → self-hosting only**, enduro не затронут). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`, сквозной `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`.
## Нотификации / Telegram live-tracker (ORCH-042/066/067/087) ## Нотификации / Telegram live-tracker (ORCH-042/066/067/087)
Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки: Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки:
- **Дефолт `tracker_mode``bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`). - **Дефолт `tracker_mode``bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`).
@@ -111,6 +113,46 @@ created → analysis → architecture → development → review → testing →
Детали — `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, Детали — `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`,
`docs/architecture/adr/adr-0018-auto-label-gates.md`. `docs/architecture/adr/adr-0018-auto-label-gates.md`.
## Отмена задачи: статус STOP (ORCH-090)
Выделенный Plane-статус **STOP** — операторская кнопка «отменить + сбросить» задачу. Вводит
**новое системное терминальное состояние `cancelled`** (стадия `tasks.stage='cancelled'` + job-исход
`jobs.status='cancelled'`), равноправное `done`. Логический ключ `stop`**fail-closed** (нет в
`_DEFAULT_STATES`, по образцу `confirm_deploy`/ORCH-059): доска без статуса STOP → ветка не
активируется. Маршрут `handle_issue_updated → handle_stop → stage_engine.cancel_task`:
- **Полный сброс** (вне критичного окна): graceful SIGTERM активного агента (`launcher.stop_process`,
переиспользует каскад `_watchdog`), все job'ы → терминальный `cancelled` (не реквью'ятся:
`claim_next_job` берёт только `queued`, reaper/worker сверяют терминал задачи — TR-2), удаление
worktree + **рабочей** Gitea-ветки (`gitea.delete_remote_branch`, **никогда** `main`, без
force-push), durable `stage='cancelled'` + **тумбстон** натуральных ключей (`plane_id`/
`work_item_id`/`plane_issue_id` → суффикс `#cancelled-<id>`; ADR-001 D4 уточнён: тумбстонится и
`plane_issue_id`, т.к. `get_task_by_plane_id`/`create_task_atomic` матчат по нему — иначе re-create
коллизирует; исходный UUID парсится из суффикса для аудита). Docs-артефакты (`01..17`) сохраняются.
- **STOP в критичном окне merge/deploy** (ADR-001 D7): `cancel.in_critical_window`**отложенная**
отмена: `tasks.cancel_requested_at`, снимаются только `queued` job'ы (running-актор деплоя/мержа не
трогается), алерт; детерминированный finalizer (`run_deploy_finalizer`) доводит необратимый шаг до
честного исхода и применяет отмену (`force=True`). «Критичное окно» = реально начатый необратимый
шаг: INITIATED-sentinel self-deploy (ORCH-036; детач-деплой + поздний `merge_pr` в
`_handle_merge_verify` идут под тем же маркером) **либо** держание merge-lease (ORCH-043) **И**
активно бегущий актор (running-job). **Уточнение P1 (ORCH-090 review):** держание merge-lease в
Phase A на стадии `deploy` в ожидании ручного `Confirm Deploy` БЕЗ бегущего актора **полностью
обратимо** (ничего не смержено/задеплоено) → НЕ критично → немедленный полный сброс (сам отпускает
lease). Иначе отмена откладывалась бы к finalizer'у, который оператор (нажавший STOP именно чтобы НЕ
подтверждать деплой) не запускает — задача застревала бы с удержанным lease, клиня serial-gate репо.
STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс (NFR-3).
- **Кросс-каттинг (adr-0026):** предикат «задача терминальна» расширен `{done}``{done, cancelled}`
в `serial_gate`/`task_deps`/`stages.py`-сток (иначе отменённая задача заклинит очередь репо);
reconciler-терминал-скип уже знал `cancelled` (ORCH-086). `STAGE_TRANSITIONS` exit-гейты рёбер /
`QG_CHECKS` / `check_*`**не тронуты** (`cancelled` — сток, не ребро).
- **Дыра релонча закрыта (D6):** relaunch агента в `handle_status_start` ограничен стадией `analysis`
(единственный владелец Needs Input, ORCH-066); ручной перевод существующей задачи в иной промежуточный
статус больше не релончит середину пайплайна. Запуск пайплайна — только «To Analyse» → `start_pipeline`.
- Флаги `stop_status_enabled` (kill-switch; `False` → всё инертно, нулевая регрессия) / `stop_status_repos`
(CSV; пусто → все репо). Leaf `src/cancel.py` (never-raise). Read-only блок `stop` в `GET /queue`.
Аддитивные колонки `tasks.cancelled_at`/`cancel_requested_at` (`_ensure_column`). **Инфра-предусловие:**
создать статус **STOP** с группой `cancelled` на доске ORCH (его отсутствие = fail-safe no-op). Детали —
`docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`,
`docs/architecture/adr/adr-0026-stop-cancel-task.md`.
## Конвенции ## Конвенции
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`

View File

@@ -138,6 +138,8 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` | | `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` |
| `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` | | `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` |
| `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` | | `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` |
| `ORCH_STOP_STATUS_ENABLED` | Kill-switch отмены задачи по Plane-статусу **STOP** + закрытия дыры релонча (ORCH-090); `false` → поведение 1:1 как до ORCH-090 | `true` |
| `ORCH_STOP_STATUS_REPOS` | CSV область репо для STOP-отмены; пусто = все репо (ORCH-090) | `""` |
## Очередь задач (ORCH-1 / F-2b) ## Очередь задач (ORCH-1 / F-2b)
@@ -154,7 +156,30 @@ Webhook-хэндлеры больше не спавнят claude-агентов
- **Ретраи.** Упавший job (exit≠0) ретраится пока `attempts < max_attempts`, - **Ретраи.** Упавший job (exit≠0) ретраится пока `attempts < max_attempts`,
потом `failed` + Telegram-нотификация. потом `failed` + Telegram-нотификация.
Статусы job: `queued → running → done | failed`. Наблюдаемость — через `GET /queue`. Статусы job: `queued → running → done | failed`; **`cancelled`** — терминальный
исход STOP-отмены (ORCH-090), нигде не реквью'ится. Наблюдаемость — через `GET /queue`.
## Отмена задачи: статус STOP (ORCH-090)
Перевод задачи в выделенный Plane-статус **STOP** отменяет её: оркестратор
останавливает активного агента (graceful SIGTERM-каскад), снимает все job'ы
(терминальный `cancelled`, без авто-requeue), удаляет worktree и **рабочую**
ветку в Gitea (**никогда** `main`, без force-push), сбрасывает прогресс в
durable-терминал `tasks.stage='cancelled'` и тумбстонит натуральные ключи
(`#cancelled-<id>`), чтобы повторный «To Analyse» создал задачу **с нуля**.
Docs-артефакты (`01..17`) сохраняются. STOP во время критичного шага merge/deploy
**откладывается** до его честного завершения (никакого half-merge / рестарта
прода). Параллельно закрыта «дыра релонча»: ручной перевод в промежуточный рабочий
статус больше не релончит агента — единственный вход к запуску пайплайна остаётся
«To Analyse» (релонч агента сменой статуса разрешён только на стадии `analysis`
владельце Needs Input). Всё под kill-switch `ORCH_STOP_STATUS_ENABLED`, аддитивно,
never-raise. Наблюдаемость — блок `stop` в `GET /queue`. Деталь — `docs/work-items/
ORCH-090/06-adr/ADR-001-stop-cancel-task.md` + сквозной
`docs/architecture/adr/adr-0026-stop-cancel-task.md`.
> **Инфра-предусловие:** на доске Plane проекта ORCH создать статус **«STOP»** с
> группой `cancelled`. До создания статуса фича в fail-safe (нет UUID → ветка STOP
> не активируется).
**Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim; **Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim;
429/overload детектится по логу (transient vs permanent), transient ретраится с 429/overload детектится по логу (transient vs permanent), transient ретраится с

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,106 @@
---
work_item: ORCH-090
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-0026: Системное терминальное состояние `cancelled` — STOP-отмена задачи
Сквозной (cross-cutting) ADR. Детальное решение задачи —
`docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`.
## Статус
Proposed
## Контекст
ORCH-090 вводит Plane-статус **STOP** — единый декларативный механизм отмены задачи (остановка
агента + полный сброс прогресса). Самое́ кросс-каттинговое следствие — появление **нового
системного терминального состояния `cancelled`** (стадия `tasks.stage='cancelled'` + терминальный
job-статус `jobs.status='cancelled'`). До ORCH-090 «терминальность задачи» в горячем планировщике
была захардкожена как **`stage == 'done'`** (единственный сток в `STAGE_TRANSITIONS`), и это
определение разъехалось между подсистемами:
- `src/reconciler.py` **уже** трактует `stage in ("done","cancelled")` как терминал-скип
(ORCH-086 D2 предвосхитил `cancelled`; стр. 196) и `_is_terminal_state` по группе Plane
`{completed, cancelled}` (ORCH-068, стр. 398415).
- `src/serial_gate.py` (ORCH-088) и `src/task_deps.py` (ORCH-026) считают задачу «незавершённой»
по `stage != 'done'`**без** `cancelled`. Если ввести `cancelled`-стадию, не тронув их,
отменённая задача навсегда будет «активной»/«незавершённой зависимостью» и **заклинит очередь
репо**.
Этот ADR фиксирует `cancelled` как первоклассное терминальное состояние, равноправное `done`, и
перечисляет ВСЕ точки, где системный предикат терминальности должен его признавать.
## Решение
### Инвариант
**«Задача терминальна» ⇔ `stage ∈ {done, cancelled}`.** Это единое определение для всех
подсистем планировщика/мониторинга. `cancelled` — терминальный **сток** (не новое ребро
конвейера): exit-гейты рёбер `STAGE_TRANSITIONS` и реестр `QG_CHECKS`/`check_*` **не меняются**.
### Точки, признающие `cancelled` терминальным (исчерпывающе)
1. `src/stages.py::STAGE_TRANSITIONS` — добавить сток
`"cancelled": {"next": None, "agent": None, "qg": None}` (параллельно `done`).
2. `src/serial_gate.py``repo_has_other_unfinished` и claim-фрагмент `t2.stage != 'done'`,
snapshot: `stage != 'done'``stage NOT IN ('done','cancelled')`. **(маркер ORCH-088)**
3. `src/task_deps.py` — dep-gate и `is_task_ready`: `stage != 'done'`
`stage NOT IN ('done','cancelled')`. **(маркер ORCH-026)**
4. `src/reconciler.py` — уже покрыто скипом `stage in ("done","cancelled")` (стр. 196);
`get_active_tasks_for_reconcile` опционально сузить до `NOT IN ('done','cancelled')`.
5. `src/job_reaper.py` / `src/queue_worker.py` — перед авто-requeue dead/running-job'а сверять
терминал задачи: `stage in ("done","cancelled")` → job помечается `cancelled`, не реквью'ится.
6. `src/post_deploy.py` / `stage_engine.run_post_deploy_monitor` — монитор не тикает по
отменённой задаче (терминал-проверка/маркер `done`).
### Новые терминальные исходы
- **Job:** `jobs.status='cancelled'` — нигде не реквью'ится; `claim_next_job` выбирает только
`status='queued'` (изменений в claim нет). `mark_job` стампит `finished_at` для `cancelled`.
- **Задача:** `tasks.stage='cancelled'` + аддитивные колонки `cancelled_at`,
`cancel_requested_at` (отложенная отмена в критическом окне merge/deploy). Натуральные ключи
`plane_id`/`work_item_id` тумбстонятся (`#cancelled-<id>`) для переиспользования «To Analyse»
с нуля; `plane_issue_id` сохраняется (аудит). Детали — 08-data-requirements.md.
### Точки врезки STOP (компоненты)
- `plane.py` — маршрут `stop` (fail-closed, не в `_DEFAULT_STATES`) → `handle_stop`; гейт релонча
ограничен стадией `analysis`.
- `stage_engine.cancel_task` — оркестрация отмены (graceful SIGTERM, cancel-jobs, worktree+branch,
tombstone, notify); безопасное прерывание merge/deploy (D7 локального ADR).
- leaf `src/cancel.py` — чистая логика (`applies`/`in_critical_window`/`snapshot`), never-raise.
- `src/gitea.py``delete_remote_branch` (never-raise; только feature-ветка, `main` неприкосновенен).
- `GET /queue` — read-only блок `stop`.
### Флаги / совместимость
- Kill-switch `stop_status_enabled` + scope `stop_status_repos` (CSV, пусто → все репо).
- При `stop_status_enabled=False`: STOP-обработка и гейт релонча инертны; расширение
терминал-набора `cancelled` безвредно при отсутствии отменённых задач → **нулевая регрессия**.
- `STAGE_TRANSITIONS` (exit-гейты) / `QG_CHECKS` / `check_*` / семантика
Approved/Rejected/Confirm Deploy / merge-gate (ORCH-043) / merge-verify (ORCH-071/073) /
image-freshness (ORCH-058) / post-deploy (ORCH-021) / serial-gate FIFO (ORCH-088) / auto-label
(ORCH-089) — **без изменений**.
- Миграции БД — только аддитивные/идемпотентные (`_ensure_column`); enduro не затронут (NFR-2).
## Последствия
- **+** Единое, консистентное определение терминальности — устранён латентный рассинхрон
`done`-only между планировщиком и реконсилятором.
- **+** STOP безопасен для self-hosting: не трогает `main`/прод, отложенная отмена в критическом
окне.
- **** Терминальность теперь читается из набора `{done, cancelled}`, а не из скаляра `'done'`
будущие подсистемы обязаны использовать набор. Митигейшн: этот ADR + маркер `ORCH-090` в
изменённых местах + тесты.
- **Откат:** `stop_status_enabled=False`; полный revert — снять врезки и вернуть предикаты к
`stage != 'done'`.
## Эволюция маркеров `cancelled`-терминала
Места, признающие `cancelled` терминальным (см. список выше), несут маркер `ORCH-090`. Правка
любого из них — сверяться с этим ADR (анти-археология: 3+ маркеров → одна ссылка сюда,
TRACEABILITY.md).
## Ссылки
- Детальный ADR: `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`
- Data: `docs/work-items/ORCH-090/08-data-requirements.md`
- Связанные: adr-0017 (serial-gate), adr-0015 (task-deps), adr-0007 (self-deploy),
adr-0006 (merge-gate), adr-0018 (auto-label)

View File

@@ -0,0 +1,82 @@
---
work_item: ORCH-093
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# adr-0027: Merge-актор — ретрай транзиентных ошибок Gitea + гард «ветка уже в `main`»
Сквозной (cross-cutting) ADR. **Амендмент** к [adr-0013](adr-0013-merge-verify-gate.md) (merge-verify
под-гейт), [adr-0014](adr-0014-merge-verify-sha-source-of-truth.md) (SHA-в-main как источник истины)
и [adr-0016](adr-0016-ensure-open-pr-before-merge-verify.md) (гарантированный код-PR). Детальное
решение задачи — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`.
> Регистрируется как сквозной, т.к. правит блок merge-актора с **3+ маркерами** (`ORCH-071`,
> `ORCH-073`, `ORCH-082`) — анти-археология маркеров (`docs/_standards/TRACEABILITY.md`): сводный
> ADR агрегирует эволюцию вместо перечисления work item в коде.
## Статус
Proposed
## Контекст
Детерминированный merge-актор merge-verify под-гейта (`deploy → done`, self-hosting) состоит из
`ensure_open_pr``merge_pr``verify_merged_to_main` (`src/merge_gate.py`). Инцидент **ORCH-063
(09.06)** вскрыл два дефекта, оба сверены по коду прода:
1. `merge_pr`**one-shot**: `POST /pulls/{index}/merge`, любой не-`200/201` → мгновенный `False`.
Транзиентная икота Gitea (`405 "Please try again later"` при пересчёте `mergeable` сразу после
пуша; `5xx`; таймаут) → ложный HOLD защиты ORCH-071/073 → ручной домерж.
2. `ensure_open_pr` — после ручного мержа код-PR `closed`, открытый не найден → создаёт **новый
пустой PR** на ветке, уже целиком в `main`.
Защита ORCH-071/073 («deploy succeeded but not merged») корректна и сохраняется; задача снижает
лишь **ложные** срабатывания на транзиентах и устраняет мусорные PR. Это блокер автономного прогона
(эпик ORCH-088).
## Решение
Аддитивно, без правки `STAGE_TRANSITIONS` / `QG_CHECKS` / схемы БД; INV-4 (мерж только через Gitea
PR-merge API; никогда `push`/`force-push` в `main`) и never-raise сохранены.
- **Ретрай-loop вокруг `POST …/merge`** (только мутирующий вызов) до `merge_retry_max_attempts`
(дефолт 3) с экспоненциальным backoff и потолком (`base 2`, `max 5`; суммарно ≤10 с). Классификатор
**транзиент** (`405`/`408`/`5xx`/таймаут/сетевое; `409`/`422` при `mergeable==True`; `mergeable==None`
→ транзиент-по-дефолту в рамках бюджета) vs **терминал** (`403`/`404`; `409`/`422` при
`mergeable==False`) — по коду ответа **и** полю `mergeable` (`GET /pulls/{index}`). Терминал →
быстрый честный `False` (защита ORCH-071/073 — как прежде). Образец — `check_ci_green`
(`attempt i/N`) + transient-breaker агентов.
- **Гард already-in-main в `ensure_open_pr`**: перед созданием PR — `git merge-base --is-ancestor
<branch> origin/main` (rc==0 → ветка целиком в `main`) → новый исход `"already-in-main"`, PR не
создаётся; git-ошибка/ambiguous → **fail-OPEN** на текущий create-путь (гард не должен превратить
икоту git в ложный no-op мержа). `_handle_merge_verify` трактует `"already-in-main"` как «мержить
нечего» → пропуск `merge_pr` → авторитетный SHA-в-main (`verify_merged_to_main`, ADR-0014) доводит
до `done` без мусорного PR.
- **Конфиг**: `merge_retry_enabled` (kill-switch; `False` → one-shot, нулевая регрессия),
`merge_retry_max_attempts`, `merge_retry_backoff_base_s`, `merge_retry_backoff_max_s`
(env `ORCH_MERGE_RETRY_*`). Гард already-in-main — без отдельного флага (накрыт существующим
`merge_verify_autocreate_pr_enabled`).
Объём раската — реально только self-hosting (`merge_verify_applies`); на прочих репо мерж делает
LLM-deployer → изменение нейтрально.
## Последствия
- **+** Транзиент Gitea переживается автоматически → нет ложного HOLD / ручного домержа в автономном
конвейере; нет мусорных пустых PR; повтор финализатора идемпотентен.
- **+** Реальный конфликт → быстрый честный HOLD; защита ORCH-071/073 и SHA-в-main (ADR-0014) —
авторитетны и неизменны.
- **** Дефолт `mergeable==None → transient` может добавить ≤10 с до HOLD на реальном конфликте
(бюджет жёстко ограничен); один лишний `GET /pulls/{index}` в редком ambiguous-кейсе.
- **Откат:** `ORCH_MERGE_RETRY_ENABLED=false` → one-shot; `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=false`
→ отключает врезку `ensure_open_pr` с гардом. Полный откат — revert PR.
## Ссылки
- Детальный ADR: `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`
- Лехатая: [adr-0006](adr-0006-merge-gate.md), [adr-0013](adr-0013-merge-verify-gate.md),
[adr-0014](adr-0014-merge-verify-sha-source-of-truth.md),
[adr-0016](adr-0016-ensure-open-pr-before-merge-verify.md)
- Код: `src/merge_gate.py`, `src/stage_engine.py::_handle_merge_verify`, `src/config.py`

View File

@@ -0,0 +1,96 @@
---
work_item: ORCH-094
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# adr-0028: Terminal-window-aware гард выставления deploy-фазовых статусов Plane
Сквозной (cross-cutting) ADR. **Амендмент** к [adr-0010](adr-0010-post-deploy-monitor.md)
(post-deploy monitor, ORCH-021) и Plane-статусной модели (ORCH-066): вводит инвариант
«deploy-фазовые Plane-статусы — terminal-window-aware» поверх общих сеттеров `plane_sync` и
переупорядочивает блок `next_stage == "done"` в `advance_stage`. Детальное решение задачи —
`docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`.
> Регистрируется как сквозной, т.к. правит **общие** сеттеры `set_issue_awaiting_deploy`/
> `set_issue_deploying`/`set_issue_monitoring` (используются системно) и трогает маркированный блок с
> `ORCH-021`/`ORCH-066` (`docs/_standards/TRACEABILITY.md`).
## Статус
Proposed
## Контекст
Терминальная (`done`) задача в Plane **не держит `Done`**: непрерывный флапп
`Awaiting Deploy ⟷ Monitoring after Deploy` (верифицировано живьём на **ORCH-061**, task 47, done с
07.06 — 273 активности, само не затихает). Установлено по коду/логам/БД прода:
- Три code-писателя deploy-фазовых статусов (`src/stage_engine.py:404/1218/1316`) делегируют в тонкие
сеттеры `src/plane_sync.py`, которые **БД-стадию не читают** ⇒ терминал-слепы: любой повторный вызов
перезаписывает `Done` обратно на промежуточный статус.
- **Ordering:** `update_task_stage("done")` (`stage_engine.py:369`) пишет `tasks.stage='done'`
**раньше** легитимного `set_issue_monitoring` (стр. 404) ⇒ пост-деплой-окно ORCH-021 — by-design
индикация поверх уже-`done` задачи. Наивный гард «stage==done → Done» ⇒ регресс легитимного окна.
- Актор всех 273 переходов — бот-токен орка (`daf4d3f4-…`), не привязан к активной task/job; в БД нет
активного post-deploy-monitor для task 47 (окно 15 мин закрыто). Реконсилятор F-1 пропускает
`done`/`cancelled`, F-2 опрашивает только `[to_analyse, approved, rejected]` ⇒ механизма привести
застрявшую на deploy-статусе done-задачу к `Done` нет.
## Решение
**Единый terminal-window-aware гард на низком чокпоинте** — на входе трёх deploy-фазовых сеттеров
`plane_sync`. Чистую логику держит **новый leaf-модуль `src/deploy_status_guard.py`** (never-raise,
config-gated; образец `serial_gate.py`/`labels.py`/`cancel.py`); сеттеры исполняют вердикт.
- **Инвариант легитимности:** deploy-фазовый статус легитимен ⇔ задача **нетерминальна** ИЛИ
(`done` **И** активно пост-деплой-окно). Иначе — идемпотентное схождение к `Done`.
`decide(work_item_id, target) -> ALLOW | CONVERGE_DONE | SUPPRESS`:
kill-switch off / чужой issue / не-self репо / нетерминал → **ALLOW**; `cancelled`**SUPPRESS**;
`done` + `target==monitoring` + `window_active`**ALLOW**; `done` иначе → **CONVERGE_DONE**
(`set_issue_done`, идемпотентно); любое исключение → **ALLOW** + warning (never-raise).
- **Новый helper** `post_deploy.window_active(repo, wi)` = `has_marker(ARMED) and not
has_marker(DONE)` (restart-safe).
- **Перенос арм-блока** (`post_deploy.arm_monitor`) **перед** terminal-sync в блоке
`next_stage == "done"`: на стр. 404 `ARMED` уже записан ⇒ `window_active==True` ⇒ легитимный первый
`Monitoring` проходит; re-drive после закрытия окна сходится к `Done`.
- **Харднинг монитора:** идемпотентный страж `has_marker(...DONE)` (ранний return без PATCH/реэнкью)
+ тик no-op при `cancelled` мид-окно; тики привязаны к активному job'у (нет job → нет тика).
- **Наблюдаемость:** каждый вердикт логируется (`work_item`/`caller`/`target`/`db_stage`/
`window_active`/вердикт); подавление/схождение — явно.
- **Флаги** (`config.py`): `deploy_status_guard_enabled=True`
(`ORCH_DEPLOY_STATUS_GUARD_ENABLED`, kill-switch → 1:1) + `deploy_status_guard_repos=""`
(`ORCH_DEPLOY_STATUS_GUARD_REPOS`, пусто → self-hosting only) с локальным `applies(repo)`.
## Альтернативы
- **Гард в caller'ах `stage_engine`** — отвергнуто: не ловит неизвестный/стейл путь под бот-токеном,
размазывает инвариант.
- **Наивный «stage==done → Done» без предиката окна** — отвергнуто: регресс легитимного `Monitoring`.
- **Bypass-флаг на доверенном вызове 404** — отвергнуто в пользу переноса арм-блока (один предикат).
- **Активная сходимость в реконсиляторе F-2** — отвергнуто как основной механизм (лишний polling,
правка маркированного F-2); гард на сеттере гасит непрерывный флапп.
## Последствия
- Терминальная задача стабильно держит `Done`; маятник гаснет за один цикл независимо от актора.
- Легитимный пост-деплой `Monitoring` и рабочий self-deploy-цикл — 1:1 (предикат окна + перенос арм).
- `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — **не тронуты**.
- `main`/force-push/прод-контейнер/detached-деплой — не тронуты; не-self репо инертны.
- Ограничение: если актор флаппа — внешняя Plane-automation (вне кода орка), гард — буфер на стороне
орка; локализация (FR-1) и итог документируются (BR-7).
- **Откат:** `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` → поведение 1:1; полный — revert ветки.
## Связи
- [adr-0010](adr-0010-post-deploy-monitor.md) (ORCH-021 — пост-деплой-окно, sentinel `armed`/`done`,
арм-блок) — амендмент: окно становится предикатом легитимности `Monitoring`.
- ORCH-066 (Plane-статусная модель — слой B индикации; `deploy→done` self ⇒ `Monitoring`) — инвариант
сохранён.
- [adr-0026](adr-0026-stop-cancel-task.md) (ORCH-090 — терминал `cancelled`) — гард не штампует
deploy-статус поверх `cancelled`.
- ORCH-068/086 (терминал-скип реконсилятора) — этот ADR распространяет идею терминал-aware на
выставление deploy-статусов.
- Детально: `docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`.

View File

@@ -61,9 +61,15 @@ STAGE_TRANSITIONS = {
testing: → deploy-staging (agent: deployer, QG: check_tests_passed) testing: → deploy-staging (agent: deployer, QG: check_tests_passed)
deploy-staging: → deploy (agent: deployer, QG: check_staging_status) deploy-staging: → deploy (agent: deployer, QG: check_staging_status)
deploy: → done (agent: None, QG: None) deploy: → done (agent: None, QG: None)
cancelled: → None (agent: None, QG: None) # ORCH-090: терминал-сток отмены
} }
``` ```
**Терминальные стоки (ORCH-090):** `done` и `cancelled` — равноправные терминальные состояния
(`{"next": None, "agent": None, "qg": None}`). `cancelled` — это **не новое ребро** (exit-гейты
рёбер не меняются), а терминал STOP-отмены. Системный предикат «задача завершена» —
`stage ∈ {done, cancelled}` (синхронно в `reconciler`/`serial_gate`/`task_deps`; adr-0026).
### 3. Quality Gates (`src/qg/checks.py`) ### 3. Quality Gates (`src/qg/checks.py`)
| Check | Метод проверки | | Check | Метод проверки |
@@ -128,12 +134,16 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
**Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. **Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются.
**Строки стадий: отражение откатов + суммирование метрик (ORCH-091).** Цикл рендера строк стадий (`render_task_tracker``_stage_line`) исправлен по двум осям. (1) **Откат (Деф.2):** `✅`-строка стадии рисуется только если её позиция в конвейере `≤` текущей позиции задачи; позиция берётся из порядка `STAGE_TRANSITIONS` (read-only хелпер `_pipeline_pos`, never-raise; неизвестная стадия → «далёкое будущее» → ✅ не пере-подавляется) с нормализацией `deploy-staging → deploy` ТОЛЬКО в гейте подавления (схлопнутая строка «Внедрение» несёт `stage_key="deploy"`). После отката (`deploy-staging → development`, `review → development`) строки стадий ПОЗЖЕ текущей больше не рисуются как пройденные — пропадает абсурд «✅ Внедрение + 🔄 Разработка»; `is_active_stage` не тронут. (2) **Метрики (Деф.3):** `_stage_line` агрегирует ВСЕ `agent_runs` агента стадии (Σ cost / Σ токены / Σ время теми же per-run-формулами, что блок тоталов задачи), а не последний прогон — каждый агент привязан ровно к одной строке `_TRACKER_STAGES`, поэтому Σ(строк стадий) ≡ тоталы ≡ `SUM(agent_runs)` по `task_id`; модель/эффорт/«попытка N» берутся из последнего прогона. Прогоны, подавлённые откатом, по-прежнему входят в тоталы (намеренная семантика отката).
**Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация).** Под заголовком карточка несёт строку `📍 <Plane-статус>` по модели ORCH-066. Источник — двухслойный, контракт **never raises**: **Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация).** Под заголовком карточка несёт строку `📍 <Plane-статус>` по модели ORCH-066. Источник — двухслойный, контракт **never raises**:
- **Оффлайн-ядро** `plane_status_label(task_row)` — чистая функция БЕЗ сети: `stage → статус` (`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, `development→Development`, `review→Code-Review`, `testing→Testing`, `deploy→⏸️ Awaiting Deploy`, `done→Done`) + `⏸️ In Review` из brd-часов (`brd_review_started_at` задан, `…_ended_at` пуст). Неизвестная/битая стадия → безопасный дефолт `To Analyse`. - **Оффлайн-ядро** `plane_status_label(task_row)` — чистая функция БЕЗ сети: `stage → статус` (`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, `development→Development`, `review→Code-Review`, `testing→Testing`, `deploy-staging→Deploying (staging)` [ORCH-091], `deploy→⏸ Awaiting Deploy`, `done→Done`, `cancelled→Cancelled` [ORCH-091]) + `⏸️ In Review` из brd-часов (`brd_review_started_at` задан, `…_ended_at` пуст). **ORCH-091:** карта `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (полнота — тестом, не статичным списком); неизвестная/будущая стадия → нейтральный фолбэк (капитализированное имя стадии), а НЕ «To Analyse» (он остаётся лишь явным лейблом `created` и безопасной деградацией на истинно-битом входе).
- **Live-overlay** `_live_plane_branch_override` — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (`fetch_issue_state` с коротким `tracker_live_status_timeout_s`, TTL-кэш `tracker_live_status_ttl_s`, kill-switch `tracker_live_status`). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; `⏸️ In Review` (авторитет brd-часов) overlay не консультирует. Анти-false-positive: `deploying/monitoring`, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override. - **Live-overlay** `_live_plane_branch_override` — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (`fetch_issue_state` с коротким `tracker_live_status_timeout_s`, TTL-кэш `tracker_live_status_ttl_s`, kill-switch `tracker_live_status`). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; `⏸️ In Review` (авторитет brd-часов) overlay не консультирует. Анти-false-positive: `deploying/monitoring`, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override.
**Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без `<a>`; динамические части экранируются, `<a>`-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`). **Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без `<a>`; динамические части экранируются, `<a>`-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`).
**HTML-безопасность данных карточки (ORCH-095).** Текст карточки шлётся с `parse_mode=HTML` и собирается из слотов двух категорий: **markup** (намеренная разметка — `num_html`/`plane_issue_link`, `link_for(...)`, `_done_link(...)`, уже-экранированный `esc_title`) и **data** (подставляемые значения — длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл `_card_status_label`, имя модели `short_model_name`, эффорт `_run_effort`, токены/стоимость `fmt_tokens`/`fmt_cost`). Инвариант: **каждый data-слот экранируется `html.escape` ровно один раз на границе рендера** (`render_task_tracker`/`_stage_line`); функции-источники остаются HTML-агностичными, markup-слоты не экранируются (двойное экранирование запрещено). Это устранило класс «неэкранированные данные в HTML-тексте»: до фикса `_fmt_minutes(<60s)` возвращал литерал `<1м`, который Telegram парсил как открывающий тег → `editMessageText` `400 can't parse entities``EDIT_FAILED` → ранний `return` (анти-дубль ORCH-087) → карточка застывала (инцидент ORCH-093). `_fmt_minutes` по-прежнему возвращает `<1м` — escape на границе (`&lt;1м`) рендерит его визуально идентично; формат не меняется. Застрявшая (в окне) карточка авто-восстанавливается следующим безопасным рендером; `edit_telegram`/`update_task_tracker`/леджер сирот/режимы `bump`/`edit` не тронуты. Детали — [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md).
## Database Schema ## Database Schema
```sql ```sql
@@ -329,7 +339,7 @@ webhook (plane/gitea) background thread (queue_worker)
| Колонка | Назначение | | Колонка | Назначение |
|--------|------------| |--------|------------|
| `status` | `queued``running``done` \| `failed` | | `status` | `queued``running``done` \| `failed` \| `cancelled` (ORCH-090: терминальный исход STOP-отмены, не реквью'ится) |
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) | | `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
| `run_id` | FK на `agent_runs.id` после старта | | `run_id` | FK на `agent_runs.id` после старта |
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) | | `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-062
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,7 @@
# Business Request: Механизм отмены задачи: статус STOP в Plane (остановка + полный сброс)
Work Item ID: ORCH-090
## Description
TBD

View File

@@ -0,0 +1,165 @@
---
work_item: ORCH-090
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-090 — Механизм отмены задачи: статус STOP в Plane (остановка + полный сброс)
Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
Сегодня в оркестраторе **нет штатного способа отменить/остановить задачу**. Оператор вынужден
выполнять разрозненные ручные действия: убить процесс агента, дождаться исчерпания ретраев job,
удалить ветку/worktree/строку task из БД и вручную сбросить статус в Plane. Это медленно,
ошибкоопасно и не воспроизводимо (инцидент 09.06 с ORCH-087 — оператор делал всё это руками).
Вторая, связанная проблема — **дыра ручного релонча**: `src/webhooks/plane.py::handle_status_start`
при ручном переводе задачи в рабочий статус (через «To Analyse» / In Progress) **повторно ставит в
очередь агента текущей стадии на той же ветке** (`has_active_job_for_task` → иначе
`enqueue_job(stage_agent, …)`). Это означает, что попытка оператора «подтолкнуть» задачу сменой
статуса может незаметно релончить агента — именно этот механизм усугубил сегодняшний инцидент.
Требуется единый, декларативный механизм: **перевод задачи в новый Plane-статус STOP →
оркестратор немедленно останавливает всю работу по задаче и полностью сбрасывает её прогресс**.
Повторный запуск возможен ТОЛЬКО через «To Analyse» (с нуля). Никакой другой статус пайплайн не
запускает.
**Установленные факты (по текущему коду, не изобретать):**
- Машина стадий — `src/stages.py::STAGE_TRANSITIONS`; терминальная стадия только `done`
(`cancelled`-стадии нет).
- Plane-маппинг — `src/plane_sync.py`: `_PLANE_NAME_TO_KEY` уже содержит `"Cancelled" → "cancelled"`,
`_DEFAULT_STATES` содержит UUID `cancelled`; имени «STOP» в маппинге сейчас нет.
- Остановка процесса агента уже реализована как graceful-каскад в
`src/agents/launcher.py::_watchdog` (SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL); PID
задачи хранится в `jobs.pid`.
- Статусы job в `jobs``queued | running | done | failed`; статуса `cancelled` нет.
- Терминал-скип для реконсилятора/мониторов уже учитывает `done` и `cancelled`
(`src/reconciler.py::_is_terminal_state`, ORCH-068/086).
- Запуск пайплайна с нуля — `handle_status_start → start_pipeline` (создаёт ветку + docs + analyst).
## 2. Объём (scope)
### В объёме
- Новый Plane-статус **STOP** как сигнал отмены задачи (распознавание в диспетчере статусов).
- **Остановка задачи (G1):** graceful-стоп активного агента (SIGTERM), отмена всех job'ов задачи
(queued/running → терминальный «cancelled»-исход), исчерпание ретраев (запрет авто-requeue),
снятие таймеров/мониторов (post-deploy monitor, brd-review clock и т.п.).
- **Полный сброс прогресса (G2):** удаление/архив рабочей ветки и worktree, очистка незавершённого
прогресса задачи в БД так, чтобы повторный старт шёл строго через `start_pipeline` (с нуля).
Docs-артефакты задачи — сохранить/забэкапить (не теряем аналитику).
- **Закрытие дыры релонча (G3/G4):** перевод в любой промежуточный рабочий статус
(Development/Architecture/Review/Deploying/…) вручную **не** запускает агента; единственный вход
к запуску пайплайна — «To Analyse» (старт с нуля).
- **Идемпотентность и fail-safe (G5):** STOP на уже остановленной/завершённой задаче — no-op; STOP
во время критичной операции (merge/deploy) — корректное прерывание без порчи `main`/прода.
- Kill-switch фичи; наблюдаемость (лог + Telegram + блок в `GET /queue`).
- Обновление документации (CLAUDE.md, architecture/README.md, CHANGELOG.md) и инфра-предусловие
(создать статус STOP на доске Plane).
### Вне объёма
- Автоматическая отмена задач по таймауту/эвристике — STOP только по явному человеческому сигналу.
- Возобновление задачи «с середины» после STOP — сознательно НЕ поддерживается (только перезапуск
с нуля через To Analyse).
- Изменение семантики Rejected (откат на стадию назад) — STOP это отдельный путь, не Rejected.
- Изменение состава/семантики `STAGE_TRANSITIONS` exit-гейтов и `QG_CHECKS` / `check_*`.
- Откат уже задеплоенного в прод кода (rollback) — STOP не выполняет rollback; он лишь прерывает
незавершённую работу безопасно.
- Кросс-проектная отмена пакета задач (отменяется одна задача за сигнал).
## 3. Заинтересованные стороны
- **Заказчик / владелец продукта:** Слава (идея STOP-статуса).
- **Оператор оркестратора** (Стрим и др.) — главный потребитель: получает кнопку «отменить» вместо
ручной хирургии по БД/процессам.
- **Затрагиваемые проекты:** orchestrator (self-hosting) и enduro-trails (общая прод-БД/очередь) —
изменения должны быть аддитивны и не задевать enduro при выключенном/неприменимом флаге.
- **Принимает результат:** reviewer/tester по критериям приёмки (`03`/`04`).
## 4. Бизнес-требования (BR)
- **BR-1 (STOP останавливает работу)** — перевод задачи в Plane-статус STOP → оркестратор
останавливает всю работу по задаче: (a) активному агенту посылается SIGTERM (graceful, с
последующим жёстким kill по существующему grace-каскаду); (b) все job'ы задачи (queued и running)
переводятся в терминальный «отменённый» исход и не выбираются claim'ом; (c) ретраи исчерпываются
(никакого авто-requeue после STOP); (d) таймеры/мониторы задачи (post-deploy monitor, brd-review
clock, merge-lease defer и т.п.) снимаются. Контракт фичи — **never-raise**.
- **BR-2 (STOP = полный сброс)** — после STOP задача НЕ продолжается с середины. Рабочая
ветка+worktree удаляются/архивируются; незавершённый прогресс задачи в БД очищается или
помечается так, что повторный запуск идёт через `start_pipeline` с нуля (свежая ветка от
актуального `origin/main`, новый аналитик). Docs-артефакты (`01..17`) — сохранить/забэкапить.
- **BR-3 (единственный вход — To Analyse)** — единственный Plane-статус, запускающий пайплайн —
«To Analyse» (старт с нуля). После STOP повторный «To Analyse» создаёт задачу заново.
- **BR-4 (закрыть дыру релонча)** — ручной перевод задачи в любой промежуточный рабочий статус
(Architecture/Development/Review/Testing/Deploying/Awaiting Deploy/…) **не** запускает агента
соответствующей стадии. Текущее поведение `handle_status_start`, релончащее агента текущей стадии
на той же ветке, должно быть устранено/загейчено так, чтобы пайплайн стартовал только из
«To Analyse».
- **BR-5 (идемпотентность)** — STOP на задаче, которая уже остановлена (cancelled), уже `done` или
не существует, — **no-op** (без ошибок, без побочных эффектов, без повторного kill).
- **BR-6 (безопасное прерывание критичных операций)** — STOP во время merge/deploy не оставляет
`main` в half-merged состоянии и не роняет/не рестартит прод-контейнер. Если критичный шаг уже
необратимо запущен (детач-деплой/слияние в процессе), STOP не должен его «разорвать» с порчей —
допустимо дождаться/пропустить необратимый шаг и зафиксировать честный итог (детали безопасной
точки прерывания — архитектору).
- **BR-7 (STOP ≠ Rejected)** — STOP это полная остановка+сброс задачи, а не откат на предыдущую
стадию. Существующий путь Rejected (`handle_verdict(approved=False)``_rollback_stage`) не
меняется и не смешивается с STOP.
- **BR-8 (наблюдаемость)** — каждое срабатывание STOP прозрачно: лог, Telegram-уведомление (с
кликабельным номером задачи), Plane-коммент (best-effort), отражение в live-карточке и read-only
блок в `GET /queue`.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (нулевая регрессия + kill-switch)** — фича под флагом включения (по образцу
`serial_gate_enabled`/`merge_gate_enabled`); при выключенном флаге поведение оркестратора строго
как сейчас. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / семантика существующих статусов — без
изменений.
- **NFR-2 (общая прод-БД, аддитивность)** — любые изменения схемы БД — только аддитивные и
идемпотентные (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`); enduro-trails не затрагивается.
- **NFR-3 (self-hosting safety)** — STOP не должен убить сам оркестратор / прод и не портить `main`.
Прерывание merge/deploy — fail-safe (не оставлять half-merge; не рестартить прод).
- **NFR-4 (restart-safe)** — состояние «задача отменена» durable (БД); после рестарта контейнера
отменённая задача не «оживает» и не релончится reconciler'ом/reaper'ом (переиспользовать
терминал-скип `done`/`cancelled`).
- **NFR-5 (never-raise)** — обработчик STOP и закрытие дыры релонча не должны валить вебхук-поток;
ошибка на единице работы логируется и не прерывает обработку других задач/проектов.
- **NFR-6 (offline-устойчивость горячего пути)** — закрытие дыры релонча и терминал-скип не должны
добавлять обязательных сетевых вызовов в горячий claim-цикл.
## 6. Допущения и ограничения
- На доске Plane проекта ORCH будет создан статус **STOP** (инфра-предусловие); до его создания
фича в режиме fail-safe (нет статуса → нет STOP-действия, ничего не ломается).
- Логический ключ `cancelled` и его UUID/группа уже присутствуют в `plane_sync` — STOP может
переиспользовать «cancelled»-семантику терминал-скипа (точное соответствие имя→ключ и
группа-`cancelled` — решение архитектора).
- Существующий graceful kill-каскад агента (`_watchdog`: SIGTERM→grace→SIGKILL) переиспользуется
для остановки активного агента; новый механизм kill не изобретается.
- Терминал-скип `done`/`cancelled` в `reconciler`/мониторах уже есть и должен покрыть
STOP-отменённые задачи (NFR-4) — переиспользовать, не дублировать.
- Архитектурные решения (хранилище статуса отмены, точка безопасного прерывания merge/deploy,
удаление vs архив ветки, точные точки врезки в `plane.py`) — зона архитектора (`06-adr/`).
## 7. Критерии успеха
STOP-статус, выставленный на задаче, приводит к: остановленному агенту, отменённым job'ам без
авто-requeue, снятым таймерам/мониторам, удалённой/заархивированной ветке+worktree, durable-статусу
«отменена» (переживает рестарт), сохранённым docs-артефактам. Ручной перевод в промежуточный
рабочий статус более не релончит агента; пайплайн стартует только из «To Analyse». STOP
идемпотентен и безопасен при merge/deploy. Детальные PASS/FAIL — в `03-acceptance-criteria.md`.
## 8. Риски
- Гонка «STOP во время merge/deploy» → риск half-merge/порчи `main` (mitigation: безопасная точка
прерывания, fail-safe — детали архитектору).
- Закрытие дыры релонча может задеть легитимный сценарий resume после «Needs Input» → нужно
сохранить намеренные сценарии возврата к работе, не ломая их (уточнить с архитектором, какой путь
заменяет релонч).
- Очистка прогресса в БД при общей прод-БД → риск задеть enduro/другие задачи (mitigation:
строго per-task, аддитивно).
- Детали — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,191 @@
---
work_item: ORCH-090
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-090 — Механизм отмены задачи: статус STOP в Plane (остановка + полный сброс)
Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **что** и **где** должно измениться (модули/контракты/артефакты), выведенное из BRD
> и фактического кода. **Как** (хранилище статуса отмены, точка безопасного прерывания merge/deploy,
> удаление vs архив ветки, точные точки врезки) — решает архитектор в `06-adr/`. ТЗ фиксирует
> требования и границы, не предлагает архитектурное решение.
---
## 1. Сводка изменения
Ввести обработку нового Plane-статуса **STOP** как сигнала отмены задачи. При его получении
оркестратор: (1) останавливает активного агента (graceful SIGTERM через существующий каскад),
(2) отменяет все job'ы задачи и исчерпывает ретраи, (3) снимает таймеры/мониторы, (4) удаляет/
архивирует рабочую ветку+worktree и сбрасывает незавершённый прогресс в БД до состояния «отменена»
(durable), сохраняя docs-артефакты. Параллельно закрывается **дыра релонча**: ручной перевод в
промежуточный рабочий статус больше не запускает агента — единственный вход к запуску пайплайна
остаётся «To Analyse» (`start_pipeline`). Всё — аддитивно, под kill-switch, never-raise,
restart-safe. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*` и семантика существующих статусов —
**не меняются**.
---
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/webhooks/plane.py` | изменить: добавить распознавание/маршрутизацию STOP (`handle_issue_updated`) → новый обработчик `handle_stop` (имя — на усмотрение архитектора); **загейтить/убрать релонч агента** в `handle_status_start` (промежуточные статусы не запускают агента; пайплайн — только из `To Analyse`/`start_pipeline`) |
| `src/agents/launcher.py` | изменить: предоставить/переиспользовать остановку активного процесса задачи (SIGTERM каскад `_watchdog`; `jobs.pid`), пометку «не релончить» (исчерпание `max_attempts`/запрет авто-requeue для отменённой задачи) |
| `src/queue_worker.py` / `src/db.py` | изменить: отмена job'ов задачи (queued/running → терминальный «cancelled»-исход); claim не выбирает отменённые; helper'ы выборки job'ов задачи; (возможно) новый терминальный статус job `cancelled` ИЛИ переиспользование `failed`+флаг — выбор архитектора; durable-пометка задачи «отменена» в `tasks` |
| `src/git_worktree.py` | изменить/переиспользовать: удаление/архив рабочей ветки и worktree отменённой задачи (`remove_worktree`; удаление/архив Gitea-ветки) — never-raise |
| `src/plane_sync.py` | изменить: маппинг Plane-статуса STOP (`_PLANE_NAME_TO_KEY` / `_DEFAULT_STATES`); переиспользовать группу `cancelled` для терминал-скипа; сеттер статуса (best-effort) |
| `src/stages.py` | при необходимости — терминальная трактовка отменённой задачи (НЕ менять exit-гейты рёбер; добавление `cancelled`-стадии — решение архитектора, см. §5) |
| `src/reconciler.py` | переиспользовать терминал-скип `done`/`cancelled` (`_is_terminal_state`) — отменённая задача не реконсилируется/не релончится |
| `src/job_reaper.py` | согласовать: reaper не «оживляет» отменённые job'ы (терминальный исход не requeue'ится) |
| `src/stage_engine.py` | согласовать: снятие таймеров/мониторов (post-deploy monitor, brd-review clock) и безопасное прерывание merge/deploy при STOP |
| `src/notifications.py` | переиспользовать `send_telegram`/`update_task_tracker` для алерта/карточки отмены (never-raise, кликабельный номер) |
| `src/config.py` | изменить: новый kill-switch `stop_status_enabled` (+ при необходимости область репо/доп-флаги) по образцу `serial_gate_enabled` |
| `src/main.py` | изменить: read-only блок наблюдаемости отмены в `GET /queue` (аддитивно) |
| `docs/architecture/README.md`, `CLAUDE.md`, `CHANGELOG.md` | обновить в том же PR (golden source) |
| `tests/` | добавить тесты (см. `04-test-plan.yaml`) |
> Чистую логику распознавания/решения по STOP желательно вынести в leaf-модуль (по образцу
> `src/serial_gate.py` / `src/labels.py`, never-raise) — окончательно решает архитектор.
---
## 3. Функциональные требования
### FR-1 — Распознавание и маршрутизация STOP (BR-1, BR-5)
- `handle_issue_updated` (`webhooks/plane.py`) распознаёт перевод задачи в логический статус STOP
(через `_PLANE_NAME_TO_KEY`/группа `cancelled`) и маршрутизирует в обработчик отмены.
- Обработчик идемпотентен: если задача уже отменена / `done` / отсутствует → no-op (BR-5).
- Контракт — never-raise: ошибка обработки STOP логируется, вебхук-поток не падает (NFR-5).
### FR-2 — Остановка активного агента (BR-1a)
- Для running-job'а задачи послать активному процессу SIGTERM (graceful) через существующий
каскад `launcher._watchdog` (SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL); PID берётся
из `jobs.pid`.
- Если активного процесса нет (idle/queued) — шаг no-op.
### FR-3 — Отмена job'ов и исчерпание ретраев (BR-1b, BR-1c)
- Все job'ы задачи (`status IN (queued, running)`) переводятся в **терминальный отменённый исход**
так, что `claim_next_job` их больше не выбирает и `_finalize_*`/reaper не делает авто-requeue.
- Запрет авто-requeue: после STOP `attempts` считаются исчерпанными (либо отдельный терминальный
статус job `cancelled`, либо `failed`+маркер — выбор архитектора). Reaper (`job_reaper.py`) и
`_finalize_permanent` не должны возвращать отменённый job в `queued`.
### FR-4 — Снятие таймеров и мониторов (BR-1d)
- При STOP снимаются/обнуляются связанные с задачей таймеры и фоновые наблюдатели: post-deploy
monitor (ORCH-021), brd-review clock (ORCH-087), отложенные defer'ы merge-lease/serial-gate.
- Терминал-скип `done`/`cancelled` (`reconciler._is_terminal_state`, ORCH-068/086) применяется к
отменённой задаче, чтобы реконсилятор/мониторы её не трогали (NFR-4).
### FR-5 — Полный сброс прогресса (BR-2)
- Рабочая ветка и worktree задачи удаляются/архивируются (`git_worktree.remove_worktree` + удаление/
архив Gitea-ветки; never-raise). `main` не трогается, force-push в `main` запрещён.
- Незавершённый прогресс задачи в БД приводится к durable-состоянию «отменена» так, что повторный
запуск возможен ТОЛЬКО через `start_pipeline` с нуля (новая ветка от свежего `origin/main`, новый
analyst). Конкретика «очистить строку vs пометить cancelled» — архитектору; инвариант:
возобновления «с середины» не происходит.
- **Docs-артефакты задачи (`01..17`) сохраняются/бэкапятся** — не удаляются вместе с прогрессом.
### FR-6 — Закрытие дыры релонча (BR-3, BR-4)
- `handle_status_start` (или эквивалентная точка) **не должен релончить агента текущей стадии** при
ручном переводе в промежуточный рабочий статус (Architecture/Development/Review/Testing/
Deploying/Awaiting Deploy/Monitoring/…).
- Запуск пайплайна остаётся возможен **только** через статус «To Analyse» → `start_pipeline`
(создание ветки + docs + enqueue analyst). Любой намеренный сценарий «вернуть задачу в работу»
(например, после Needs Input) должен быть пересмотрен так, чтобы НЕ опираться на авто-релонч
агента сменой рабочего статуса (точный заменяющий механизм — архитектору).
### FR-7 — Безопасное прерывание критичных операций (BR-6, NFR-3)
- STOP во время merge/deploy не оставляет `main` в half-merged состоянии и не рестартит/не роняет
прод-контейнер. Если необратимый шаг (detached self-deploy / слияние PR) уже запущен — STOP не
«разрывает» его с порчей: допускается дать необратимому шагу завершиться/зафиксировать честный
исход, после чего применить отмену. Точка безопасного прерывания и обработка merge-lease — ADR.
### FR-8 — Наблюдаемость (BR-8)
- Каждое срабатывание STOP: `logger.info/warning` (что остановлено/сброшено), Telegram-алерт
(`send_telegram`, кликабельный номер `plane_issue_link`), Plane-коммент (best-effort), обновление
live-карточки (`update_task_tracker`, never-raise), read-only блок отмены в `GET /queue`.
---
## 4. Изменения API
- **Новых обязательных публичных endpoint'ов нет.** Триггер STOP — смена статуса Plane (webhook),
не REST. (По аналогии с ORCH-088 возможен опциональный админ-эндпоинт принудительной отмены —
на усмотрение архитектора; если вводится, описать в ADR и таблице API README.)
- `GET /queue`**аддитивно**: новый read-only блок (например `stop`/`cancel`) — флаг `enabled`,
счётчик отменённых задач/job'ов, последние отмены. Существующие ключи не меняются; never-raise.
- Внешний контракт вебхука `POST /webhook/plane` — не меняется (новая ветка обработки статуса
внутри `handle_issue_updated`).
---
## 5. Изменения схемы БД
> Только **аддитивные, идемпотентные** миграции (общая прод-БД; enduro не трогать).
> `CREATE TABLE IF NOT EXISTS` / `_ensure_column`.
- **Статус job «отменён» (FR-3):** требуется терминальный исход, который не requeue'ится. Варианты
(выбор — архитектор): новый статус `jobs.status='cancelled'` ИЛИ переиспользование `failed` +
аддитивный маркер. Требование к выбранному варианту: claim/finalize/reaper не возвращают его в
`queued`; restart-safe.
- **Состояние задачи «отменена» (FR-5, NFR-4):** durable-признак, что задача отменена и не
возобновляется с середины. Варианты: добавление терминальной стадии `cancelled` в `tasks.stage`
(учитывается терминал-скипом `done`/`cancelled`, уже поддержан reconciler'ом) ИЛИ аддитивная
колонка/таблица. `STAGE_TRANSITIONS` (exit-гейты рёбер) при этом **не меняются** — отмена это
терминальное состояние, не новое ребро конвейера.
- `QG_CHECKS`, `check_*`, `job_deps`, `agent_runs`-контракт, `repo_freeze`**без изменений**.
---
## 6. Требования к новым/изменённым QG checks
- **Новых QG-проверок не вводить.** STOP — это решение диспетчера статусов/планировщика (отмена),
а не Quality Gate стадии. Реестр `QG_CHECKS` и `check_*` не меняются (по образцу `task_deps`
ORCH-026 и `serial_gate` ORCH-088 — логика в обработчике/claim, не новый QG).
---
## 7. Совместимость / регресс
- **Kill-switch:** новый флаг `stop_status_enabled` (env `ORCH_STOP_STATUS_ENABLED`) по образцу
`serial_gate_enabled`; `False` → STOP-обработка и закрытие дыры релонча ведут себя нейтрально
(поведение строго как сейчас, нулевая регрессия). При необходимости — область репо
(`stop_status_repos`, CSV) с дефолтом «все репо» (отмена осмысленна и для enduro).
- **Аддитивность БД (NFR-2):** только идемпотентные миграции; enduro при выключенном/неприменимом
флаге не затрагивается.
- **Инварианты (не нарушать):** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды
deploy-хука, merge-gate (ORCH-043), merge-verify (ORCH-071/073), image-freshness (ORCH-058),
post-deploy контракт (ORCH-021), serial-gate (ORCH-088), auto-label (ORCH-089), семантика
Rejected/Approved/Confirm Deploy — **без изменений**.
- **Self-hosting safety (NFR-3):** STOP не рестартит/не роняет прод-контейнер; не push/force-push в
`main`; merge/deploy прерываются fail-safe (без half-merge).
- **never-raise (NFR-5):** обработчик STOP и закрытие релонча не валят вебхук-поток; ошибка на
единице работы изолирована.
- **Артефакты pipeline (создать/обновить в том же PR):** `docs/work-items/ORCH-090/06-adr/ADR-001-…`
(решение архитектора), `docs/architecture/README.md` (раздел «STOP / отмена задачи (ORCH-090)»,
обновление описания `GET /queue`, раздела статусной модели и при новой таблице/колонке — раздела
«База данных»), `CLAUDE.md` (абзац о STOP в статусной модели), `CHANGELOG.md` (`feat:`); при новой
таблице/колонке — `docs/work-items/ORCH-090/08-data-requirements.md`; при админ-эндпоинте — таблица
API в README.
---
## 8. Открытые вопросы для архитектора (не блокируют анализ)
- OQ-1: Имя Plane-статуса — отдельный «STOP» (новый key) vs переиспользование существующего
«Cancelled» (key `cancelled` уже в `_PLANE_NAME_TO_KEY`). Влияет на маппинг и группу терминал-скипа.
- OQ-2: Статус отменённого job — новый `cancelled` vs `failed`+маркер.
- OQ-3: Состояние отменённой задачи — терминальная стадия `cancelled` vs аддитивная колонка/таблица.
- OQ-4: Сброс прогресса — удалить строку task (полный re-create через To Analyse) vs пометить
cancelled и при To Analyse создавать новую задачу.
- OQ-5: Удаление vs архив рабочей ветки (и Gitea-ветки) — что безопаснее для аудита.
- OQ-6: Точка безопасного прерывания merge/deploy (FR-7) и обработка удерживаемого merge-lease.
- OQ-7: Чем заменить легитимный «resume после Needs Input», который сейчас опирается на релонч в
`handle_status_start` (FR-6), чтобы не сломать намеренный сценарий возврата к работе.

View File

@@ -0,0 +1,146 @@
---
work_item: ORCH-090
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-090 — Механизм отмены задачи: статус STOP в Plane
Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам
репозитория.
---
## AC-1 — STOP останавливает активного агента
**Условие:** задача с running-job'ом переведена в Plane-статус STOP.
- **PASS:** активному процессу агента послан SIGTERM через существующий каскад
(`launcher._watchdog`: SIGTERM → grace → SIGKILL); по grace процесс завершён; `agent_runs`/`jobs`
отражают завершение. Тест демонстрирует вызов остановки по `jobs.pid`.
- **FAIL:** процесс агента продолжает работать после STOP, либо kill реализован новым «грязным»
механизмом мимо graceful-каскада, либо STOP падает с исключением.
---
## AC-2 — Все job'ы задачи отменены без авто-requeue
**Условие:** у задачи есть job'ы в `queued` и/или `running`; пришёл STOP.
- **PASS:** все job'ы задачи переведены в терминальный отменённый исход; `claim_next_job` их не
выбирает; `_finalize_permanent`/`job_reaper` не возвращают их в `queued` (ретраи исчерпаны).
Тест: после STOP claim не возвращает job задачи, reaper не requeue'ит.
- **FAIL:** хотя бы один job задачи остаётся claimable/возвращается в `queued` после STOP, либо
происходит авто-requeue.
---
## AC-3 — Таймеры/мониторы сняты, отменённая задача не реконсилируется
**Условие:** задача отменена через STOP.
- **PASS:** связанные таймеры/мониторы (post-deploy monitor, brd-review clock, defer'ы) не активны
для задачи; `reconciler` (`_is_terminal_state`, терминал-скип `done`/`cancelled`) и `job_reaper`
не трогают/не «оживляют» отменённую задачу. Тест: reconciler F-1 пропускает отменённую задачу.
- **FAIL:** монитор/таймер срабатывает по отменённой задаче, либо reconciler/reaper её
релончит/реанимирует.
---
## AC-4 — Полный сброс: ветка/worktree удалены/архивированы, прогресс сброшен, docs сохранены
**Условие:** задача отменена через STOP.
- **PASS:** рабочий worktree удалён (`remove_worktree`, never-raise), рабочая ветка удалена/
заархивирована; `main` не тронут (force-push в `main` отсутствует); прогресс задачи в БД приведён
к durable-состоянию «отменена» (повторный запуск возможен только с нуля); docs-артефакты
(`docs/work-items/ORCH-090/01..17`) **сохранены/забэкаплены**, не удалены.
- **FAIL:** worktree/ветка остаются как «живой» прогресс, либо тронут `main`, либо docs-артефакты
удалены, либо задача способна продолжиться «с середины».
---
## AC-5 — Единственный вход к запуску — To Analyse; дыра релонча закрыта
**Условие:** существующая задача (с веткой/прогрессом) вручную переведена в промежуточный рабочий
статус (Architecture/Development/Review/Testing/Deploying/Awaiting Deploy/Monitoring).
- **PASS:** агент соответствующей стадии **не** запускается (нет `enqueue_job` стадийного агента по
факту ручной смены рабочего статуса). Запуск пайплайна происходит ТОЛЬКО при статусе «To Analyse»
(`start_pipeline`). Тест: перевод в Development не порождает job; перевод в To Analyse порождает
старт с нуля.
- **FAIL:** ручной перевод в любой промежуточный рабочий статус релончит агента текущей стадии
(текущее дырявое поведение `handle_status_start`).
---
## AC-6 — Идемпотентность STOP
**Условие:** STOP приходит на задачу, которая уже отменена / `done` / не существует.
- **PASS:** обработчик — no-op: нет повторного kill, нет повторного удаления ветки, нет ошибок, нет
Telegram-спама дублями. Тест: повторный STOP не меняет состояние и не бросает.
- **FAIL:** повторный STOP бросает исключение, повторно убивает/чистит, либо генерирует
дубль-уведомления.
---
## AC-7 — Безопасное прерывание merge/deploy (self-hosting safety)
**Условие:** STOP приходит во время merge/deploy задачи.
- **PASS:** `main` не остаётся в half-merged состоянии; прод-контейнер не рестартится/не роняется
обработчиком STOP; force-push в `main` отсутствует. Если необратимый шаг уже запущен — он не
«разрывается» с порчей (исход зафиксирован честно, затем применена отмена). Тест/обоснование
демонстрирует fail-safe точку прерывания.
- **FAIL:** после STOP `main` в неконсистентном состоянии, прод перезапущен/упал по вине STOP, либо
выполнен force-push в `main`.
---
## AC-8 — Kill-switch и нулевая регрессия
**Условие:** флаг `stop_status_enabled=False`.
- **PASS:** STOP-обработка не активна, дыра релонча в поведении не меняется относительно текущего
кода; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` не изменены; полный `pytest tests/` зелёный;
enduro-trails не затронут. При `True` — STOP работает по AC-1…AC-7.
- **FAIL:** при выключенном флаге поведение отличается от текущего; изменены exit-гейты/реестр QG;
регресс существующих тестов.
---
## AC-9 — Аддитивность БД и restart-safe
**Условие:** изменения схемы БД и поведение после рестарта.
- **PASS:** все миграции аддитивны и идемпотентны (`CREATE TABLE IF NOT EXISTS`/`_ensure_column`);
после рестарта контейнера отменённая задача остаётся отменённой и не релончится. Тест: повторная
инициализация БД не падает; отменённая задача durable.
- **FAIL:** деструктивная/неидемпотентная миграция, изменение существующих таблиц-контрактов, либо
«оживание» отменённой задачи после рестарта.
---
## AC-10 — Наблюдаемость STOP
**Условие:** STOP применён к задаче.
- **PASS:** факт отмены залогирован; отправлен Telegram-алерт с кликабельным номером задачи;
Plane-коммент (best-effort); live-карточка обновлена (never-raise); `GET /queue` несёт read-only
блок отмены. Тест: блок присутствует в ответе `GET /queue`.
- **FAIL:** STOP не оставляет следов в логе/уведомлениях, либо `GET /queue` падает/не отражает
отмену.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-2 |
| AC-2 | BR-1 / FR-3 |
| AC-3 | BR-1 / FR-4 / NFR-4 |
| AC-4 | BR-2 / FR-5 |
| AC-5 | BR-3, BR-4 / FR-6 |
| AC-6 | BR-5 / FR-1 |
| AC-7 | BR-6 / FR-7 / NFR-3 |
| AC-8 | NFR-1 / FR-6 |
| AC-9 | NFR-2, NFR-4 / FR-3, FR-5 |
| AC-10 | BR-8 / FR-8 |

View File

@@ -0,0 +1,107 @@
work_item: ORCH-090
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
title: "STOP-статус: отмена задачи (остановка + полный сброс) и закрытие дыры релонча"
framework: pytest
scope: >
Покрывается: распознавание/маршрутизация STOP, остановка агента, отмена job'ов без авто-requeue,
снятие мониторов/терминал-скип, полный сброс ветки/worktree/прогресса при сохранении docs,
закрытие дыры релонча (только To Analyse стартует пайплайн), идемпотентность, kill-switch,
аддитивность БД/restart-safe, наблюдаемость (GET /queue, уведомления).
Вне покрытия: реальный прод-деплой/рестарт контейнера (self-hosting safety проверяется на уровне
«не вызывается рестарт/force-push», а не живым деплоем); кросс-проектная пакетная отмена.
notes: >
Полный регресс `pytest tests/` должен оставаться зелёным (NFR-1). Регрессом считается: изменение
STAGE_TRANSITIONS/QG_CHECKS/check_*, релонч агента ручной сменой рабочего статуса, авто-requeue
отменённого job, «оживание» отменённой задачи reconciler/reaper, любой push/force-push в main,
рестарт прод-контейнера обработчиком STOP. Тесты должны проходить и при stop_status_enabled=False
(нейтральное поведение). Использовать существующие фикстуры из tests/test_plane_webhook.py /
test_launcher.py / test_queue.py / test_reconciler.py.
tests:
- id: TC-01
type: unit
description: "STOP-статус распознаётся и маршрутизируется в обработчик отмены (handle_issue_updated); неизвестная/прочая задача -> no-op, never-raise."
module: tests/test_stop_status.py
expected: PASS
- id: TC-02
type: unit
description: "Остановка активного агента: при STOP по running-job посылается SIGTERM по jobs.pid через каскад _watchdog (SIGTERM->grace->SIGKILL); нет активного процесса -> no-op."
module: tests/test_stop_status.py
expected: PASS
- id: TC-03
type: unit
description: "Отмена job'ов: queued+running job'ы задачи переведены в терминальный отменённый исход; claim_next_job их не выбирает (AC-2)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-04
type: unit
description: "Запрет авто-requeue: _finalize_permanent/job_reaper не возвращают отменённый job в queued (ретраи исчерпаны)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-05
type: unit
description: "Полный сброс: при STOP вызывается remove_worktree и удаление/архив рабочей ветки; main не трогается; force-push в main отсутствует (AC-4, AC-7)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-06
type: unit
description: "Docs-артефакты задачи (01..17) сохраняются/бэкапятся при сбросе прогресса, не удаляются (AC-4)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-07
type: unit
description: "Идемпотентность: повторный STOP на уже отменённой / done / несуществующей задаче -> no-op (нет повторного kill/cleanup, нет исключений, нет дубль-уведомлений) (AC-6)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-08
type: unit
description: "Kill-switch: при stop_status_enabled=False STOP-обработка нейтральна, поведение как сейчас; при True -> отмена выполняется (AC-8)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-09
type: unit
description: "Наблюдаемость: GET /queue несёт read-only блок отмены; never-raise при ошибке построения блока (AC-10)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-10
type: integration
description: "Закрытие дыры релонча: ручной перевод существующей задачи в Development/Architecture/Review/Testing НЕ порождает job стадийного агента (handle_status_start не релончит) (AC-5)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-11
type: integration
description: "Единственный вход: перевод в To Analyse запускает start_pipeline (новая ветка от свежего origin/main + analyst) — единственный путь старта пайплайна (AC-5)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-12
type: integration
description: "Терминал-скип/restart-safe: отменённая задача durable; reconciler F-1 и job_reaper её не реконсилируют/не оживляют (терминал-скип done/cancelled, _is_terminal_state) (AC-3, AC-9)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-13
type: integration
description: "End-to-end STOP: задача со срезанной веткой и активным job -> STOP -> агент остановлен, job'ы отменены, ветка/worktree убраны, статус задачи durable 'отменена', уведомления отправлены (AC-1..AC-4, AC-10)."
module: tests/test_stop_status.py
expected: PASS
- id: TC-14
type: unit
description: "Аддитивность БД: миграция нового терминального исхода job/состояния задачи идемпотентна (повторная init_db не падает); существующие таблицы-контракты не изменены (AC-9, NFR-2)."
module: tests/test_stop_status.py
expected: PASS

View File

@@ -0,0 +1,294 @@
---
work_item: ORCH-090
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-001: Механизм отмены задачи — Plane-статус STOP (остановка + полный сброс)
Work Item: **ORCH-090** — единый декларативный механизм отмены/сброса задачи через
Plane-статус STOP.
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0026-stop-cancel-task.md`** (решение
кросс-каттинговое — вводит системное терминальное состояние `cancelled`, затрагивающее
планировщик, реконсилятор, serial-gate, task-deps, мониторы).
## Статус
Proposed
---
## Контекст
Сегодня в оркестраторе **нет штатного способа отменить/остановить задачу** (BRD §1). Оператор
делает ручную хирургию: убивает процесс агента, ждёт исчерпания ретраев job, чистит
ветку/worktree/строку `tasks` и сбрасывает статус Plane. Медленно, ошибкоопасно,
невоспроизводимо (инцидент 09.06 с ORCH-087).
Вторая, связанная проблема — **дыра релонча**. Сверено по коду
`src/webhooks/plane.py::handle_status_start` (строки 215306): при существующей задаче без
активного job функция **безусловно релончит агента текущей стадии** на той же ветке
(`has_active_job_for_task(task_id)` → иначе `enqueue_job(stage_agent, …)`, где
`stage_agent = STAGE_AUTHORS.get(current_stage)`). Этот путь задуман для «аналитик ответил на
Needs Input», но релончит агента **любой** стадии — именно он усугубил инцидент.
**Факты, сверенные по коду (не изобретать):**
- Машина стадий — `src/stages.py::STAGE_TRANSITIONS`; `done` — терминальный сток
(`{"next": None, "agent": None, "qg": None}`, строка 21). `cancelled`-стадии нет.
- Plane-маппинг — `src/plane_sync.py`: `_PLANE_NAME_TO_KEY` уже содержит
`"Cancelled" → "cancelled"` (стр. 141); `_DEFAULT_STATES` содержит UUID `cancelled` (стр. 102);
имени «STOP» в маппинге нет. Маршрутизация статуса — `handle_issue_updated` (стр. 129173),
сравнивает `new_state` с per-project UUID из `get_project_states(project_id)`; `to_analyse →
handle_status_start`, `confirm_deploy → handle_confirm_deploy`, `approved/rejected →
handle_verdict`; всё прочее → `else` (no-op).
- Остановка процесса агента уже есть — graceful-каскад `launcher._watchdog`
(SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL, стр. 661718); PID задачи стампится в
`jobs.pid` (`_spawn`, стр. 607614).
- Статусы job в `jobs``queued | running | done | failed` (`src/db.py`, стр. 5672); claim
выбирает только `status='queued'` (`claim_next_job`, стр. 586651). Реквью на dead-running —
`job_reaper._reap_unknown_outcome` (`attempts<max → queued`, иначе `failed`, стр. 315334).
- **Терминал-скип уже учитывает `cancelled`:** `reconciler._is_terminal_state` (group
`completed`/`cancelled` или логический ключ `cancelled`, стр. 398415) и F-1 пропускает
`stage in ("done","cancelled")` ДО любой работы (стр. 196, ORCH-086 D2 — `cancelled`-стадия
**уже предвосхищена**).
- **Но** «незавершённость» задачи в горячем планировщике определена как `stage != 'done'`
(БЕЗ `cancelled`) в `src/serial_gate.py` (стр. 115, 120, 270, 334) и `src/task_deps.py`
(`stage != 'done'`). Новая терминальная стадия `cancelled`, не распознанная здесь, **заклинит
очередь** репо (serial-gate сочтёт отменённую задачу «активной»; task-deps — «незавершённой
зависимостью»).
- `remove_worktree(repo, branch)` — never-raise локальная очистка (`src/git_worktree.py`,
стр. 98107); функции удаления Gitea-ветки **нет**.
- Запуск с нуля — `handle_status_start → start_pipeline` (ветка + docs + analyst, стр. 430626);
`create_task_atomic` с anti-dup по `plane_id`; uniqueness-guard по `work_item_id`
(`ensure_unique_work_item_id`).
Требуется единый, декларативный, обратимый, аддитивный механизм под kill-switch, never-raise,
restart-safe; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` — без изменений (TRZ §1, NFR-1).
---
## Решение
### Сводка
Ввести **STOP** как сигнал отмены задачи: новый логический Plane-ключ `stop` (fail-closed, по
образцу `confirm_deploy`/ORCH-059), маршрутизируемый в новый обработчик `handle_stop`. Обработчик
переводит задачу в **новое системное терминальное состояние `cancelled`** (стадия + durable),
останавливая активного агента существующим graceful-каскадом, отменяя все job'ы новым
терминальным исходом `jobs.status='cancelled'`, снимая таймеры/мониторы, удаляя рабочую
ветку+worktree (docs сохраняются) и **тумбстоня** натуральные ключи (`plane_id`/`work_item_id`),
чтобы повторный «To Analyse» создал задачу с нуля. Параллельно закрывается дыра релонча:
relaunch в `handle_status_start` ограничивается единственным легитимным владельцем Needs-Input —
стадией `analysis`. Чистая логика — leaf `src/cancel.py` (never-raise); оркестрация —
`stage_engine.cancel_task`. Всё под флагом `stop_status_enabled`.
**Ключевой кросс-каттинг (см. adr-0026):** системный предикат «задача терминальна» расширяется с
`{done}` до `{done, cancelled}` в трёх горячих местах планировщика (serial-gate, task-deps,
`stages.py`-сток), приводя их в соответствие с уже существующим терминал-скипом реконсилятора.
### D1 — Распознавание и маршрутизация STOP (FR-1, BR-1, BR-5)
- В `_PLANE_NAME_TO_KEY` добавить `"STOP" → "stop"`. **В `_DEFAULT_STATES` ключ `stop` НЕ
добавляется** — fail-closed по образцу ORCH-059: нет UUID-фолбэка для enduro/API-сбоя →
`get_project_states(...).get("stop")` вернёт `None` → ветка просто не активируется (нет
`KeyError`, нет слепой отмены). Инфра-предусловие — создать статус STOP на доске ORCH с
**группой `cancelled`** (07-infra-requirements.md), чтобы терминал-скип по группе работал
нативно.
- `handle_issue_updated`: добавить ветку `stop_state = proj_states.get("stop")`
`elif stop_state and new_state == stop_state: await handle_stop(data, project_id)`. Ставится
**до** `to_analyse`/`approved`/`rejected`, чтобы жесты не алиасили.
- `handle_stop` (новый, в `plane.py`): резолвит задачу по `get_task_by_plane_id`; делегирует в
`stage_engine.cancel_task(task_id, …)`. Гард kill-switch + repo-scope через `cancel.applies(repo)`.
- **Идемпотентность (BR-5):** если задача отсутствует / уже `stage in ("done","cancelled")`
no-op (без повторного kill/удаления/уведомления). Контракт — never-raise (NFR-5): ошибка
логируется, вебхук-поток не падает.
### D2 — Остановка активного агента (FR-2, BR-1a)
Переиспользовать существующий graceful-каскад, **не изобретать новый kill**. Для running-job'а
задачи взять `jobs.pid` и послать `SIGTERM` через путь `launcher._watchdog`
(SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL). Вынести из `_watchdog` переиспользуемый
хелпер `launcher.stop_process(pid, run_id)` (тот же каскад + `_record_kill`), вызываемый и из
cancel-пути. Нет активного процесса (idle/queued) → шаг no-op. **Никогда** не убивать
detached-процесс self-deploy (см. D7).
### D3 — Отмена job'ов и запрет авто-requeue (FR-3, BR-1b/1c)
- Новый **терминальный** статус job `jobs.status='cancelled'`. Схема не меняется (`status` — TEXT);
расширяется лишь набор допустимых значений → `queued|running|done|failed|cancelled`.
- Хелпер `db.cancel_jobs_for_task(task_id)` — guarded UPDATE
`SET status='cancelled', finished_at=datetime('now') WHERE task_id=? AND status IN ('queued','running')`.
`mark_job` расширяется: `cancelled` тоже стампит `finished_at` (в наборе с `done|failed`).
- **claim не трогается:** `claim_next_job` выбирает только `status='queued'``cancelled`
исключён нативно (NFR-6 — без новых JOIN'ов в горячий путь).
- **Запрет авто-requeue (race-safe):** `job_reaper._reap_unknown_outcome` и launch-error requeue
в `queue_worker` ПЕРЕД реквью читают терминальное состояние задачи; `stage in
("done","cancelled")` → job помечается `cancelled` (терминал), **без** возврата в `queued`.
Это закрывает гонку «SIGTERM послан, job ещё `running`, reaper видит dead-pid → реквью».
Источник истины «не оживлять» — **стадия задачи `cancelled`**, а не статус job.
### D4 — Durable терминал задачи + переиспользование ключей (FR-5, NFR-4, BR-2/BR-3)
- Durable-состояние = **`tasks.stage='cancelled'`**. Это уже понимается терминал-скипом
реконсилятора (стр. 196) → **ноль нового кода** для NFR-4; после рестарта отменённая задача не
оживает (`requeue_running_jobs` флипает только `running`, а job'ы — `cancelled`).
- Аддитивная колонка `tasks.cancelled_at TEXT` (через `_ensure_column`) — durable-метка
времени для аудита/наблюдаемости.
- **Переиспользование натуральных ключей (BR-3):** чтобы повторный «To Analyse» создал задачу с
нуля, на cancel выполняется **тумбстон** ключей отменённой строки:
`plane_id := plane_id || '#cancelled-' || id`, `work_item_id := work_item_id || '#cancelled-' || id`.
Тогда `get_task_by_plane_id(plane_id)` вернёт `None``start_pipeline` создаст свежую задачу
(новая ветка от актуального `origin/main`, новый analyst); anti-dup `create_task_atomic` и
`ensure_unique_work_item_id` не коллизируют. Поле `plane_issue_id` **сохраняется** нетронутым —
аудит-связь с issue Plane не теряется. Строка `tasks` **не удаляется** (история + durable
терминал).
- **Возобновления «с середины» нет** — единственный вход к запуску остаётся `start_pipeline`
через «To Analyse» (D6).
### D5 — Системное терминальное состояние `cancelled` (кросс-каттинг — adr-0026)
Расширить предикат «задача терминальна/завершена» с `{done}` до `{done, cancelled}` там, где он
сейчас захардкожен как `stage != 'done'`, **приводя планировщик в соответствие с уже
существующим терминал-скипом реконсилятора** (стр. 196, `{done, cancelled}`):
- `src/serial_gate.py``repo_has_other_unfinished` (стр. 115/120), claim-фрагмент
`t2.stage != 'done'` (стр. 270), snapshot (стр. 334): `stage != 'done'`
`stage NOT IN ('done','cancelled')`. Иначе отменённая задача навсегда заблокирует репо.
**Маркер ORCH-088** — сверено по `src/serial_gate.py` и
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`: инвариант serial-gate — «не входить в
analysis, пока есть **более ранняя незавершённая** задача»; «незавершённость» определяется
стадией, и расширение терминал-набора `cancelled` лишь harmonизирует определение, не меняя
FIFO-семантику (`t2.id < jobs.task_id`).
- `src/task_deps.py` — dep-gate `t.stage != 'done'` и `is_task_ready`: `NOT IN ('done','cancelled')`.
Иначе отменённая зависимость заблокирует зависимые задачи навсегда. **Маркер ORCH-026**
сверено: зависимость считается «готовой», когда предшественник терминален; `cancelled`
терминальный исход, поэтому его включение в готовность корректно.
- `src/stages.py::STAGE_TRANSITIONS` — добавить терминальный **сток**
`"cancelled": {"next": None, "agent": None, "qg": None}` (параллельно `done`, стр. 21). Это
**не новое ребро** — ни одно exit-гейт ребра не меняется (TRZ §5, NFR-1); сток лишь делает
`get_next_stage('cancelled')` корректным (None).
- `src/reconciler.py::get_active_tasks_for_reconcile` (фильтр `stage != 'done'`) опционально
сузить до `NOT IN ('done','cancelled')` (микро-оптимизация; функционально уже покрыто скипом
стр. 196).
### D6 — Закрытие дыры релонча (FR-6, BR-3/BR-4, OQ-7)
Маршрутизация уже игнорирует промежуточные статусы (Architecture/Development/… → `else`, no-op),
поэтому реальная дыра — relaunch внутри `handle_status_start` при `To Analyse` на существующей
задаче. Решение: **ограничить relaunch единственным легитимным владельцем Needs-Input —
стадией `analysis`** (единственный, кто ставит Needs Input, ORCH-066). Конкретно: ветку
«existing task + idle agent → `enqueue_job(stage_agent,…)`» загейтить условием
`current_stage == 'analysis'`. Для существующей задачи любой иной стадии «To Analyse» → **no-op**
(лог + best-effort Plane-коммент «для перезапуска с нуля: STOP → To Analyse»). Это сохраняет
легитимный «аналитик ответил на вопросы», но устраняет тихий релонч середины пайплайна на старой
ветке (инцидент ORCH-087). Гейт — под `stop_status_enabled` (AC-8: флаг off → поведение 1:1 как
сейчас).
### D7 — Безопасное прерывание merge/deploy (FR-7, BR-6, NFR-3, AC-7)
STOP **никогда** не трогает `main`, не делает force-push, не рестартит/не роняет прод-контейнер и
**не SIGKILL'ит** detached-процесс self-deploy. `cancel_task` классифицирует «критическое окно» по
существующим маркерам (чистая функция `cancel.in_critical_window(task)`, never-raise):
- self-deploy Phase B запущен — sentinel `INITIATED` в `<repos_dir>/.deploy-state-<repo>/<wi>/`
(ORCH-036);
- задача держит merge-lease `<repos_dir>/.merge-lease-<repo>.json` / merge в процессе (ORCH-043/071).
**Вне критического окна** — полный сброс немедленно (D2D4, D8).
**Внутри критического окна** — отложенная отмена: ставится durable-метка
`tasks.cancel_requested_at` (аддитивная колонка), отменяются **только `queued`** job'ы (не
running-актор деплоя/мержа), шлётся алерт «STOP отложен до завершения критичного шага».
Детерминированный finalizer (`run_deploy_finalizer` Phase C / `_handle_merge_verify`) **доводит
необратимый шаг до честного исхода** и на терминальном `advance_stage` сверяется с
`cancel_requested_at`: задача переводится в `cancelled` с очисткой (worktree/ветка; код, уже
влитый в `main`, **не откатывается** — rollback вне объёма, BRD §2). Если шаг достиг `done`
STOP фиксируется как «no-op после завершения» (честно: код уже в проде). Так AC-7 выполняется без
порчи `main`/прода.
### D8 — Полный сброс ветки/worktree, сохранение docs (FR-5, BR-2, AC-4)
- `git_worktree.remove_worktree(repo, branch)` — снять worktree (never-raise, уже есть).
- **Удалить удалённую feature-ветку** через новый never-raise хелпер
`gitea.delete_remote_branch(repo, branch)` (Gitea `DELETE /repos/{owner}/{repo}/branches/{branch}`).
Удаляется **только** ветка задачи; `main` — никогда; force-push отсутствует. Выбор «удалить» (не
архив): ветку легко восстановить из Gitea, а аналитику хранят docs — минимум новой Gitea-логики
(OQ-5).
- **Docs-артефакты (`01..17`) сохраняются** — не удаляются. На диске они в `docs/work-items/ORCH-090/`
(merge'ятся отдельным PR); cancel их не трогает. (Бэкап = они уже в `origin/main`/ветке docs.)
### D9 — Флаги, leaf-модуль, наблюдаемость (FR-8, BR-8, NFR-1, AC-10)
- `src/config.py`: `stop_status_enabled: bool = True` (env `ORCH_STOP_STATUS_ENABLED`,
kill-switch) + `stop_status_repos: str = ""` (CSV; **пусто → все репо**, отмена осмысленна и для
enduro; токены санитайзятся `^[A-Za-z0-9._-]+$`) — по образцу `serial_gate_*`.
- Leaf `src/cancel.py` (never-raise, импортирует только `config`/`db`, лениво `plane_sync`): чистая
логика — `applies(repo)`, `in_critical_window(task)`, `snapshot()`. Оркестрация
(SIGTERM/cancel-jobs/worktree/branch/tombstone/notify) — `stage_engine.cancel_task` (там уже есть
доступ к launcher/db/notifications/plane_sync).
- Наблюдаемость: `logger.info/warning`, Telegram-алерт (`send_telegram`, кликабельный
`plane_issue_link`), Plane-коммент (best-effort), `update_task_tracker` (never-raise),
read-only блок `stop` в `GET /queue` (`cancel.snapshot()`: `enabled`/`repos`/счётчик
`stage='cancelled'`/последние отмены). Существующие ключи `/queue` не меняются.
---
## Альтернативы
- **Переиспользовать существующий статус «Cancelled» (key `cancelled`) вместо нового «STOP»** —
отвергнуто: владелец продукта явно хочет операторскую кнопку «STOP», отличную от встроенного
Plane-«Cancelled» (которым наблюдатели могут пользоваться иначе). Терминал-семантику группы
`cancelled` мы при этом переиспользуем (D1, D5).
- **Job-статус `failed`+маркер вместо нового `cancelled`** (OQ-2) — отвергнуто: `failed`
семантически реквью-абелен (reaper/worker путь `attempts<max → queued`); отдельный
терминальный `cancelled`, нигде не реквью'ящийся, самодокументируем и безопаснее.
- **Удалять строку `tasks` целиком** (OQ-4) — отвергнуто: теряется durable-аудит и durable
терминал в БД; тумбстон ключей (D4) даёт переиспользование с нуля, сохраняя строку и аудит.
- **Архивировать ветку (rename `archive/…`)** вместо удаления (OQ-5) — отвергнуто: лишняя
Gitea-логика; удаление обратимо в Gitea, аналитику хранят docs.
- **Прерывать merge/deploy жёстко (kill detached)** (OQ-6) — отвергнуто: риск half-merge/порчи
`main`/прода (NFR-3); отложенная отмена (D7) безопаснее.
- **Полностью блокировать «To Analyse» на существующей задаче** (D6) — отвергнуто: сломает
легитимный resume аналитика после Needs Input; ограничение релонча стадией `analysis` точечнее.
---
## Последствия
- **+** Оператор получает декларативную кнопку «отменить+сбросить» вместо ручной хирургии;
воспроизводимо, наблюдаемо, обратимо (kill-switch).
- **+** Дыра релонча закрыта; тихий релонч середины пайплайна на старой ветке исключён.
- **+** Терминал-набор планировщика приведён в соответствие с реконсилятором (`{done,cancelled}`)
— устранён латентный рассинхрон ORCH-086.
- **+** Аддитивно/идемпотентно; при `stop_status_enabled=False` — нулевая регрессия; enduro не
затронут.
- **** Вводится **системная терминальная стадия `cancelled`** — затрагивает несколько горячих
предикатов (serial-gate/task-deps/stages). Митигейшн: исчерпывающий список точек в adr-0026 +
тесты на «отменённая задача не клинит очередь / не реквью'ится / переживает рестарт».
- **** Отложенная отмена в критическом окне (D7) — не мгновенная. Митигейшн: прозрачный алерт
«STOP отложен»; необратимый шаг доводится до честного исхода; код в `main` не откатывается (в
объёме BRD — STOP ≠ rollback).
- **** Тумбстон `work_item_id` меняет значение колонки на отменённой строке. Митигейшн: формат
суффикса `#cancelled-<id>` детерминирован и парсится для аудита; `plane_issue_id` нетронут.
- **Откат:** `stop_status_enabled=False` отключает обработку STOP, гейт релонча и
freeze-неотносимые ветки; аддитивные колонки (`cancelled_at`/`cancel_requested_at`) и расширение
терминал-набора инертны при отсутствии отменённых задач. Полный revert — снять врезки в
`plane.py`/`stage_engine.py`/`serial_gate.py`/`task_deps.py`/`stages.py`, leaf `cancel.py`, флаги.
---
## Ссылки
- BRD: `docs/work-items/ORCH-090/01-brd.md`
- TRZ: `docs/work-items/ORCH-090/02-trz.md`
- Acceptance: `docs/work-items/ORCH-090/03-acceptance-criteria.md`
- Data: `docs/work-items/ORCH-090/08-data-requirements.md`
- Infra: `docs/work-items/ORCH-090/07-infra-requirements.md`
- Риски: `docs/work-items/ORCH-090/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0026-stop-cancel-task.md`
- Сверено по коду: `src/webhooks/plane.py`, `src/plane_sync.py`, `src/db.py`,
`src/queue_worker.py`, `src/agents/launcher.py`, `src/reconciler.py`, `src/job_reaper.py`,
`src/serial_gate.py`, `src/task_deps.py`, `src/stages.py`, `src/git_worktree.py`,
`src/post_deploy.py`, `src/main.py`
- Маркеры (сверено перед изменением, TRACEABILITY.md): ORCH-088 (`serial_gate`), ORCH-026
(`task_deps`), ORCH-086/068 (терминал-скип reconciler), ORCH-036/059 (self-deploy phases),
ORCH-043/071 (merge-gate/merge-verify), ORCH-021 (post-deploy), ORCH-087 (brd-clock)

View File

@@ -0,0 +1,51 @@
---
work_item: ORCH-090
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 07 — Инфра-требования: ORCH-090 — Механизм отмены задачи (STOP)
Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: architecture
## I-1. Топология / окружения
Без изменения топологии. Тот же прод-контейнер `orchestrator` (8500) и staging (8501), та же
общая SQLite-БД и очередь. STOP — обработка вебхука внутри существующего сервиса; новых
контейнеров/портов/томов/сетей нет.
**Инфра-предусловие (обязательно):** на доске Plane проекта ORCH создать статус **«STOP»** с
**группой `cancelled`** (а не `started`/`unstarted`). Группа `cancelled` обеспечивает нативный
терминал-скип реконсилятора (`_is_terminal_state`, ORCH-068/086) без доп-кода. До создания
статуса фича в fail-safe: `get_project_states(...).get("stop")``None` → ветка STOP не
активируется (нет `KeyError`, ничего не ломается). После создания — сбросить кэш состояний
(`reload_project_states`) или дождаться TTL `ORCH_PLANE_STATES_TTL_S` (дефолт 300с).
> Для enduro-trails статус STOP **не** обязателен: `stop` отсутствует в `_DEFAULT_STATES`
> (fail-closed), отмена для enduro станет доступна только при создании статуса на их доске.
## I-2. Переменные окружения / секреты
Новые env (в `.env.example`, аддитивно; секретов нет):
- `ORCH_STOP_STATUS_ENABLED` — kill-switch фичи (дефолт `true`).
- `ORCH_STOP_STATUS_REPOS` — CSV области репо (дефолт пусто → все репо).
Существующие переиспользуются: `ORCH_AGENT_KILL_GRACE_SECONDS` (graceful kill), Gitea-токен
(`delete_remote_branch`), Telegram-токен (алерт). Новых секретов нет.
## I-3. Деплой / рестарт
Прод-деплой орка — обязательно через staging-гейт (8501) перед `deploy` (self-hosting инвариант,
INFRA.md). STOP-обработчик сам **никогда** не рестартит/не роняет прод-контейнер и не трогает
`main` (NFR-3): при STOP во время self-deploy критичный detached-шаг не прерывается — отмена
откладывается до его честного завершения (ADR-001 D7). Раскат — поэтапно через `stop_status_repos`
при необходимости; дефолт «все репо».
## I-4. CI/CD
Без изменений `.gitea/workflows/`. Добавляются только pytest-тесты (`tests/`, см.
`04-test-plan.yaml`): STOP-каскад, запрет авто-requeue, терминал-скип, закрытие дыры релонча,
kill-switch, аддитивность миграций.

View File

@@ -0,0 +1,70 @@
---
work_item: ORCH-090
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 08 — Требования к данным: ORCH-090 — Механизм отмены задачи (STOP)
Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: architecture
> Общая прод-БД (orchestrator + enduro). Все изменения — **только аддитивные и идемпотентные**
> (`_ensure_column`); существующие таблицы-контракты не переопределяются (NFR-2, AC-9).
## Изменения схемы БД
### Таблица `tasks` — аддитивные колонки (через `_ensure_column`)
| Колонка | Тип | Назначение |
|---------|-----|------------|
| `cancelled_at` | `TEXT` | durable-метка времени отмены (аудит/наблюдаемость). NULL для неотменённых. |
| `cancel_requested_at` | `TEXT` | durable-метка «отмена запрошена, но отложена» (STOP в критическом окне merge/deploy, ADR-001 D7). Снимается при доведении отмены до конца. |
Никаких `ALTER` существующих колонок. `init_db` идемпотентен (повторный вызов — no-op).
### Без DDL-изменений (расширение допустимых значений TEXT)
- **`jobs.status`** — добавляется значение `cancelled` к набору `queued|running|done|failed`.
Колонка уже `TEXT`; DDL не меняется. `claim_next_job` выбирает только `status='queued'`
`cancelled` исключён нативно.
- **`tasks.stage`** — добавляется терминальное значение `cancelled` (сток, параллельно `done`).
Колонка уже `TEXT DEFAULT 'created'`; DDL не меняется. `STAGE_TRANSITIONS` exit-гейты рёбер
**не меняются**`cancelled` это терминальное состояние, не новое ребро.
### Без изменений
`job_deps`, `agent_runs`, `repo_freeze`, `tracker_messages`, индексы — контракты нетронуты.
`QG_CHECKS` / `check_*` — без изменений.
## Новые/изменённые сущности
### Тумбстон натуральных ключей отменённой задачи (ADR-001 D4)
На cancel выполняется UPDATE отменённой строки `tasks`:
- `plane_id := plane_id || '#cancelled-' || id`
- `work_item_id := work_item_id || '#cancelled-' || id`
- `stage := 'cancelled'`, `cancelled_at := datetime('now')`
- `plane_issue_id`**сохраняется нетронутым** (аудит-связь с issue Plane).
Цель: освободить натуральные ключи, чтобы повторный «To Analyse» создал свежую задачу
(`get_task_by_plane_id(plane_id)``None`; anti-dup `create_task_atomic` /
`ensure_unique_work_item_id` не коллизируют), сохранив строку для аудита. Формат суффикса
`#cancelled-<id>` детерминирован и парсится.
### Отмена job'ов (ADR-001 D3)
`cancel_jobs_for_task(task_id)` — guarded UPDATE
`SET status='cancelled', finished_at=datetime('now') WHERE task_id=? AND status IN ('queued','running')`.
Терминальный исход, нигде не реквью'ящийся.
## Совместимость данных / миграции
- **Аддитивность/идемпотентность:** только `_ensure_column` (no-op если колонка есть) и
расширение наборов TEXT-значений; деструктивных/несовместимых миграций нет (AC-9). Повторная
`init_db` после рестарта не падает.
- **Restart-safe (NFR-4):** durable терминал = `tasks.stage='cancelled'` (уже понимается
терминал-скипом реконсилятора, стр. 196). После рестарта `requeue_running_jobs` флипает только
`running` → отменённые job'ы (`cancelled`) не оживают; отменённая задача не реконсилируется.
- **Влияние на общую прод-БД:** изменения строго per-task; enduro не затрагивается, при
`stop_status_enabled=False` или отсутствии отменённых задач — поведение БД 1:1 как сейчас.
- **Кросс-каттинг (adr-0026):** предикат «задача незавершена» в `serial_gate`/`task_deps`
расширяется `stage != 'done'``stage NOT IN ('done','cancelled')`, иначе отменённая задача
заклинит очередь репо. Чтение БД (offline hot-path) не приобретает новых сетевых вызовов (NFR-6).

View File

@@ -0,0 +1,42 @@
---
work_item: ORCH-090
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-090 — Механизм отмены задачи (STOP)
Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: architecture
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **Отменённая задача клинит очередь репо**`serial_gate`/`task_deps` считают `cancelled` «незавершённой» (`stage != 'done'`) → serial-gate блокирует репо, dep-gate вечно держит зависимые. | Выс. | Выс. | Расширить предикат до `stage NOT IN ('done','cancelled')` во ВСЕХ точках (adr-0026, исчерпывающий список). Тест: после STOP другая задача репо стартует; зависимая разблокируется. |
| TR-2 | **Гонка reaper/worker реквью** — SIGTERM послан, job ещё `running`, reaper видит dead-pid → `attempts<max → queued` (авто-requeue отменённой задачи). | Сред. | Выс. | Источник истины «не оживлять» — `tasks.stage='cancelled'`. reaper/worker ПЕРЕД реквью сверяют терминал задачи → помечают job `cancelled`, не реквью'ят. Тест: reaper не возвращает job отменённой задачи в `queued`. |
| TR-3 | **STOP во время merge/deploy → half-merge / порча `main` / рестарт прода.** | Низ. | Крит. | D7: критическое окно (`INITIATED`-sentinel self-deploy, держание merge-lease) → отложенная отмена; необратимый шаг доводится до честного исхода; STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс. Тест/обоснование fail-safe точки. |
| TR-4 | **Коллизия натуральных ключей при повторном «To Analyse»** — старая отменённая строка держит `plane_id`/`work_item_id` → anti-dup/uniqueness блокируют пере-создание. | Сред. | Сред. | Тумбстон ключей `#cancelled-<id>` на cancel (D4); `plane_issue_id` сохранён. Тест: после STOP «To Analyse» создаёт свежую задачу без коллизии. |
| TR-5 | **Очистка прогресса в общей прод-БД задевает enduro/другие задачи.** | Низ. | Выс. | Все операции строго per-`task_id`; тумбстон/cancel-jobs гардятся `WHERE task_id=?`; аддитивные миграции; при `stop_status_enabled=False` — инертно. Тест: enduro-строки не тронуты. |
| TR-6 | **Закрытие дыры релонча ломает легитимный resume аналитика после Needs Input.** | Сред. | Сред. | Relaunch ограничивается стадией `analysis` (единственный владелец Needs-Input, ORCH-066), а не блокируется целиком (D6). Тест: To Analyse на `analysis` релончит аналитика; на середине пайплайна — no-op. |
| TR-7 | **STOP на «Cancelled»-группе без явного статуса STOP** — fail-closed `stop` не в `_DEFAULT_STATES` может удивить (на доске нет статуса → отмены нет). | Низ. | Низ. | Документировано как fail-safe (07-infra); инфра-предусловие — создать статус STOP (группа `cancelled`). Наблюдаемость: блок `stop` в `/queue` показывает `enabled`/`repos`. |
| TR-8 | **Дубль-уведомления / повторный kill при повторном STOP.** | Низ. | Низ. | Идемпотентность (BR-5/D1): `stage in ("done","cancelled")` → no-op до любых действий. Тест: повторный STOP не меняет состояние и не шлёт дубль. |
| TR-9 | **`delete_remote_branch` падает / ветка уже удалена / Gitea недоступна.** | Низ. | Низ. | never-raise хелпер: ошибка/404 логируется, отмена продолжается; worktree снимается локально независимо; `main` не трогается. |
| TR-10 | **Удаление feature-ветки теряет код, не влитый в `main`.** | Низ. | Сред. | По замыслу: STOP = сброс незавершённого прогресса (BRD §2). docs-артефакты (`01..17`) сохраняются; ветку можно восстановить в Gitea. Влитый в `main` код не откатывается (rollback вне объёма). |
## Сводный вывод
Доминирующий класс — **консистентность системного терминал-набора** (TR-1, TR-2): введение
`cancelled` как первоклассного терминала обязывает синхронно обновить ВСЕ предикаты «задача
завершена», иначе латентный клин очереди. Это покрыто исчерпывающим списком в adr-0026 и
маркером `ORCH-090`. Второй класс — **self-hosting safety при STOP во время merge/deploy** (TR-3),
покрыт отложенной отменой (D7) с жёсткими запретами (`main`/прод/force-push/kill detached).
**Эскалация:** решение вводит **новое системное терминальное состояние `cancelled`** (новая
стадия-сток + новый job-статус + сквозное изменение предиката терминальности) → классифицируется
как `arch:major-change`. Возврат в анализ **не требуется**ТЗ полно, OQ-1…OQ-7 разрешены в
ADR-001; реализация аддитивна, под kill-switch, с нулевой регрессией при выключенном флаге.
Остаточный риск для прод-конвейера (self-hosting) — **низкий** при условии полного покрытия
тестами TR-1/TR-2/TR-3 и обязательного staging-гейта перед прод-деплоем.

View File

@@ -0,0 +1,114 @@
---
verdict: APPROVED
work_item: ORCH-090
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-09
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-090
version: 2
---
# Review ORCH-090 — Механизм отмены задачи: статус STOP (re-review, attempt 2)
## Summary
Повторный review после фикса блокирующего P1 из предыдущей итерации (`12-review.md` v1).
Реализация STOP-отмены аккуратна и канонична (leaf `src/cancel.py` never-raise, kill-switch
`stop_status_enabled`, fail-closed маршрутизация по образцу `confirm_deploy`/ORCH-059, аддитивные
идемпотентные миграции). Кросс-каттинг `{done}``{done, cancelled}` проведён исчерпывающе и
консистентно (serial_gate / task_deps / stages / db / job_reaper / queue_worker), в точности по
adr-0026.
**Оба ранее блокировавших/важных дефекта закрыты и покрыты содержательными тестами:**
- **P1 (был blocker) — ИСПРАВЛЕН.** `cancel.in_critical_window` сужен: удержание merge-lease без
бегущего актора (`_task_has_running_actor`) на стадии `deploy` в ожидании `Confirm Deploy` теперь
НЕ считается критическим окном → немедленный полный сброс, который сам отпускает lease (шаг 3c).
Тесты `test_d7_lease_held_idle_parking_is_not_critical`,
`test_d7_lease_held_with_running_actor_still_critical`,
`test_d7_stop_on_deploy_awaiting_confirm_full_resets` (последний прямо проверяет
`stage='cancelled'` + удалённую ветку + `current_lease_holder is None`). Сверено по коду
`src/cancel.py::in_critical_window` (стр. 100158) и `stage_engine.cancel_task` — wedge
self-hosting-репо устранён.
- **P2 (был should-fix) — ИСПРАВЛЕН.** Deferred-ветка `cancel_task` шлёт алерт только при первом
переходе (`first = set_task_cancel_requested(...)`, далее `if first:`); повторный STOP в
критическом окне даёт `deferred-already-pending` без повторного уведомления. Тест
`test_d7_repeated_stop_in_critical_window_no_duplicate_notify` (ровно 1 notify).
Полный регресс `pytest tests/` зелёный (**1349 passed**); `tests/test_stop_status.py` — 30 кейсов
(TC-01…TC-14 + D7), покрывают AC-1…AC-10 и оба фикса.
Оси проверки: ✅ ТЗ/AC (AC-1…AC-10, включая ранее проваленный AC-7) · ✅ ADR (соответствие
adr-0026/ADR-001; см. P2-нит ниже) · ✅ качество кода · ✅ документация.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- [ ] **Work-item ADR-001 §D7 не синхронизирован с фиксом P1 (running-actor-уточнение).**
`docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md` §D7 (стр. 189201) по-прежнему
определяет критическое окно как «задача держит merge-lease … / merge в процессе» — **без**
оговорки «И активно бегущий актор», которую фактически реализует код
(`cancel.in_critical_window` + `_task_has_running_actor`) после фикса P1. Авторитетные
golden-source доки уже синхронизированы (`CLAUDE.md` — абзац «Уточнение P1 (ORCH-090 review)»;
`docs/architecture/README.md` стр. 316317 «P1-уточнение»; `CHANGELOG.md` — буллет «Фикс P1»),
поэтому витрина проекта корректна и это **не** P0 «src изменён, доки не обновлены». Но per
«documentation = golden source» работа-айтемный ADR (запись именно этого архитектурного решения)
должен честно отражать итоговую семантику — как это уже сделано для уточнения D4. Предложение:
добавить в §D7 строку-уточнение «merge-lease критичен ТОЛЬКО при бегущем акторе; припаркованное
ожидание `Confirm Deploy` обратимо → немедленный сброс» (ссылка на review P1). Не блокирует.
### P3 — Nice to have
- [ ] **«Завис» `cancel_requested_at` на успешно задеплоенной задаче → вечный `pending` в
`GET /queue`** (перенесено из v1, не адресовано). При SUCCESS-деплое `run_deploy_finalizer`
вызывает `cancel_task(force=True)`, который видит `stage='done'` → «already-terminal» no-op и
**не очищает** `cancel_requested_at`; `db.cancelled_tasks_snapshot` считает
`pending = cancel_requested_at IS NOT NULL AND stage != 'cancelled'` → done-задача с бывшим
deferred-STOP навсегда показывается «pending». Чисто наблюдаемость; предложение — очищать
`cancel_requested_at` при честном no-op после завершения.
- [ ] **adr-0026 п.6 (post-deploy monitor «не тикает по отменённой задаче») в коде не реализован**
(перенесено из v1). Фактически безвреден и недостижим: post-deploy наблюдение идёт только ПОСЛЕ
`done`, а STOP на `done` — no-op. Рекомендация: снять пункт из adr-0026 как нерелевантный либо
добавить дешёвый терминал-гард для строгого соответствия ADR.
- [ ] **Косметика:** «рваная» строковая склейка комментария relaunch-hole в
`src/webhooks/plane.py` (стр. 345351) — собрать в одну строку для читаемости.
## Документация
**Обновлена полностью и качественно — отдельных blocking-findings нет.** Проверено пофайльно:
- `README.md` — таблица env (`ORCH_STOP_STATUS_ENABLED`/`ORCH_STOP_STATUS_REPOS`), раздел «Отмена
задачи: статус STOP (ORCH-090)», обновлён список job-статусов (`cancelled`), инфра-предусловие.
- `docs/architecture/README.md` — раздел STOP со статусом «реализовано», блок `stop` в `/queue`,
раздел «База данных» (колонки/тумбстон/статусы) **и P1-уточнение** (стр. 316317).
- `docs/architecture/internals.md``STAGE_TRANSITIONS` (сток `cancelled`), терминал-предикат
`{done,cancelled}`, job-статусы.
- `CHANGELOG.md` (`feat:` + отдельный буллет «Фикс P1»), `CLAUDE.md` (раздел «Отмена задачи: статус
STOP (ORCH-090)» с абзацем «Уточнение P1»), `.env.example` — согласованы.
- ADR: локальный `06-adr/ADR-001-stop-cancel-task.md` + сквозной
`docs/architecture/adr/adr-0026-stop-cancel-task.md`; уточнение D4 (тумбстон `plane_issue_id`)
отражено в коде и доках. Единственный gap — §D7 локального ADR не дотянут до running-actor-фикса
(P2 выше).
- Раздела README «Известные ограничения», который ORCH-090 закрывал бы (ORCH-079), нет — обзорная
витрина не рассинхронена.
**Трассировка маркеров (TRACEABILITY.md):** правки маркированных инвариантов `serial_gate`/ORCH-088
и `task_deps`/ORCH-026 сверены с их ADR — расширение терминал-набора до `{done,cancelled}` сохраняет
FIFO-семантику (`t2.id < jobs.task_id`) и dep-готовность (терминальный предшественник), инварианты
не сломаны. `STAGE_TRANSITIONS` exit-гейты / `QG_CHECKS` / `check_*` — не тронуты (подтверждено
анти-регресс-снапшотами, зелёные).
## Вердикт
`APPROVED`оба ранее найденных дефекта (P1 wedge при STOP в ожидании Confirm Deploy; P2
дубль-уведомления в deferred-ветке) исправлены и покрыты содержательными тестами; полный регресс
зелёный (1349 passed). Остаются только P2 (синхронизация §D7 локального ADR) и P3 (наблюдаемость/
косметика) — не блокируют приёмку, желательны к устранению попутно.

View File

@@ -0,0 +1,95 @@
---
result: PASS
work_item: ORCH-090
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-09
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-090
---
# Test Report — ORCH-090 — Механизм отмены задачи: статус STOP (остановка + полный сброс)
> Машинный вердикт читается ТОЛЬКО из frontmatter (`result:`). Гейт `check_tests_passed`
> (`_parse_tests_verdict`) парсит его. Review-вердикт предшественника — `APPROVED` (`12-review.md` v2).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-09
- Worktree: `feature/ORCH-090-stop-plane` (`/repos/_wt/orchestrator/feature_ORCH-090-stop-plane/`)
- Прод-контейнер `orchestrator` (8500) не трогался (smoke только read-only).
## Результаты
### Полный регресс
`pytest tests/ -q` (из worktree ветки задачи) — **1349 passed, 1 warning** (37.91s).
Warning — известный pydantic v2 deprecation в `src/config.py:8` (не относится к ORCH-090, не регресс).
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` анти-регресс-снапшоты — зелёные (NFR-1).
### Профильные сюиты
`pytest tests/test_stop_status.py -v`**30 passed** (1.72s): TC-01…TC-14 + 7 кейсов D7
(безопасное прерывание merge/deploy, P1-фикс «merge-lease критичен только при бегущем акторе»,
P2-фикс «нет дубль-уведомлений в deferred-ветке»).
### Smoke API (read-only, прод 8500)
| Проверка | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | OK (active_tasks отдаётся; ORCH-090 видна на `testing`) |
| `GET /queue` → блок `serial_gate` (ORCH-088) | присутствует — OK |
| `GET /queue` → блок `auto_labels` (ORCH-089) | присутствует — OK |
> Блок `stop` (ORCH-090) в проде 8500 отсутствует — ожидаемо: фича этой задачи ещё не задеплоена
> (прод несёт предыдущий образ). В коде ветки блок присутствует (`src/main.py:198 "stop": cancel.snapshot()`)
> и покрыт тестом `test_tc09_queue_has_stop_block_and_keeps_keys` — это НЕ регресс смока.
## Сопоставление с тест-планом (`04-test-plan.yaml`)
| TC ID | Описание | Тест-функция(и) | Результат |
|-------|----------|-----------------|-----------|
| TC-01 | STOP распознаётся/маршрутизируется; прочее → no-op, never-raise | `test_tc01_stop_routed_and_unknown_is_noop` | PASS |
| TC-02 | Остановка агента: SIGTERM по `jobs.pid` через каскад `_watchdog`; idle → no-op | `test_tc02_stop_active_agent_by_pid`, `test_tc02_idle_agent_no_stop` | PASS |
| TC-03 | Отмена job'ов: queued+running → терминал; `claim_next_job` их не выбирает | `test_tc03_jobs_cancelled_and_claim_skips`, `test_tc03_cancel_jobs_helper_only_queued` | PASS |
| TC-04 | Запрет авто-requeue: `_finalize`/reaper не возвращают в `queued` | `test_tc04_reaper_does_not_requeue_terminal_task` | PASS |
| TC-05 | Полный сброс: `remove_worktree`+удаление ветки; `main` не тронут, нет force-push | `test_tc05_full_reset_removes_branch_and_worktree`, `test_tc05_delete_remote_branch_refuses_main` | PASS |
| TC-06 | Docs-артефакты (01..17) сохраняются при сбросе | `test_tc06_docs_and_task_row_survive` | PASS |
| TC-07 | Идемпотентность: повторный STOP на cancelled/done/missing → no-op | `test_tc07_idempotent_on_cancelled_done_missing` | PASS |
| TC-08 | Kill-switch `stop_status_enabled=False` нейтрален; `True` → отмена; scope CSV | `test_tc08_kill_switch_off_inert`, `test_tc08_kill_switch_off_handle_stop_noop`, `test_tc08_scope_csv` | PASS |
| TC-09 | Наблюдаемость: `GET /queue` несёт блок `stop`; never-raise при ошибке | `test_tc09_queue_has_stop_block_and_keeps_keys`, `test_tc09_snapshot_never_raises` | PASS |
| TC-10 | Дыра релонча закрыта: ручной перевод в mid-стадию НЕ порождает job | `test_tc10_relaunch_hole_closed_midpipeline` | PASS |
| TC-11 | Единственный вход — To Analyse → `start_pipeline`; analysis idle релончит analyst | `test_tc11_new_task_starts_pipeline`, `test_tc11_analysis_idle_relaunches_analyst` | PASS |
| TC-12 | Терминал-скип/restart-safe: reconciler F-1 и reaper не оживляют cancelled | `test_tc12_reconciler_skips_cancelled`, `test_tc12_requeue_running_does_not_revive_cancelled` | PASS |
| TC-13 | End-to-end STOP: агент остановлен, job'ы отменены, ветка убрана, статус durable, уведомления | `test_tc13_end_to_end_stop` | PASS |
| TC-14 | Аддитивность БД: миграция идемпотентна; существующие контракты целы | `test_tc14_migration_idempotent_and_columns_present`, `test_tc14_existing_contracts_intact` | PASS |
| — (D7) | Безопасное прерывание merge/deploy + P1/P2-фиксы | `test_d7_*` (7 кейсов) | PASS |
Все 14 TC из тест-плана выполнены и сопоставлены; ожидаемый `expected: PASS` совпадает с фактом.
## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`)
| AC | Критерий | Покрытие | Результат |
|----|----------|----------|-----------|
| AC-1 | STOP останавливает активного агента (SIGTERM-каскад по `jobs.pid`) | TC-02, TC-13 | PASS |
| AC-2 | Все job'ы отменены без авто-requeue (claim не выбирает) | TC-03, TC-04 | PASS |
| AC-3 | Таймеры/мониторы сняты; отменённая задача не реконсилируется | TC-12 | PASS |
| AC-4 | Полный сброс: ветка/worktree убраны, прогресс durable, docs сохранены | TC-05, TC-06, TC-13 | PASS |
| AC-5 | Единственный вход — To Analyse; дыра релонча закрыта | TC-10, TC-11 | PASS |
| AC-6 | Идемпотентность STOP (cancelled/done/missing) | TC-07 | PASS |
| AC-7 | Безопасное прерывание merge/deploy (нет half-merge/рестарта прода/force-push) | TC-05, D7 (`test_d7_*`) | PASS |
| AC-8 | Kill-switch и нулевая регрессия (полный pytest зелёный) | TC-08, полный регресс 1349 passed | PASS |
| AC-9 | Аддитивность БД и restart-safe | TC-14, TC-12 | PASS |
| AC-10 | Наблюдаемость STOP (`GET /queue` блок, уведомления) | TC-09, TC-13 | PASS |
Все AC-1…AC-10 покрыты и зелёные.
## Итог
**PASS.** Полный регресс зелёный (1349 passed), профильная сюита `tests/test_stop_status.py` зелёная
(30 passed), smoke read-only OK (`/health`, `/status`, `/queue` с блоками `serial_gate`/`auto_labels`),
каждый TC тест-плана выполнен и сопоставлен с AC. Регрессов (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`,
авто-requeue, оживание отменённой задачи, касание `main`/прод-контейнера) не обнаружено.
`result: PASS` → задача переходит на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-090
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,53 @@
---
staging_status: SUCCESS
work_item: ORCH-090
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-09
model_used: claude-opus-4-8
timestamp: 2026-06-09T18:30:25Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live staging environment
(`orchestrator-staging`, 8501), run canonically inside the container
(ORCH-048, ADR-001):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
**Verdict: SUCCESS** (exit code 0).
## Results
Result: 8/10 checks PASS. All REAL (pipeline) checks are green:
- **Block A (SMOKE)**: A1 `/health`, A2 `/queue`, A3 `ORCH_STAGING=true` — PASS
- **Block B (ACCESS)**: B4 Plane sandbox, B5 Gitea sandbox (push=true), B6 registry
isolation (sandbox present, prod ET/ORCH absent) — PASS
- **Block C (E2E, stub)**: C7 create issue in SANDBOX, C8 trigger pipeline via
`/webhook/plane` — PASS; C9a/C9b — waived sandbox-infra
REAL failed: none.
## Infra waiver (ORCH-061)
The two failed checks are known sandbox-infra checks (C9a branch appears in
`orchestrator-sandbox`, C9b analyst-job enqueued) — they depend on SANDBOX bot
accounts being members of the sandbox Plane project, not on the pipeline. They
were waived per ORCH-061 (`staging_infra_tolerance_enabled=True`); the script
still exited 0 fail-closed because every REAL check is green.
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
Exit code remains the source of truth (fail-closed: any REAL failure still yields
exit 1).

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: заголовок-строка карточки застревает на «To Analyse» на stage=deploy-staging (нет ключа в _STAGE_STATUS_LABEL)
Work Item ID: ORCH-091
## Description
TBD

View File

@@ -0,0 +1,137 @@
---
work_item: ORCH-091
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-091 — Карточка трекера: фикс «To Analyse» на deploy-staging, отражение откатов, суммирование метрик по попыткам
Work Item: **ORCH-091** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
Live Telegram-карточка задачи (ORCH-067, единственная карточка на задачу, рендер
`src/notifications.py::render_task_tracker`) — основной канал, по которому Owner/Слава
наблюдают прогресс конвейера. Карточка обязана показывать **честную** текущую картину.
Объединены три верифицированных по коду и БД прода (09.06) дефекта одной карточки
(ORCH-072 закрыт как дубль; ORCH-091 расширена до полного объёма):
- **Дефект 1 (косметика, но вводит в заблуждение).** Заголовок-строка статуса карточки
(`📍 <status_label>`) застревает на «To Analyse», когда задача реально на стадии
`deploy-staging`. Корень верифицирован: словарь `_STAGE_STATUS_LABEL`
(`src/notifications.py` ~стр. 940) содержит 8 ключей (`created/analysis/architecture/
development/review/testing/deploy/done`), а реальные значения `tasks.stage` — это ключи
`STAGE_TRANSITIONS` (`src/stages.py`), среди которых есть `deploy-staging`. Ровно эта
стадия не покрыта → `.get(stage, _DEFAULT_STATUS_LABEL)` отдаёт дефолт «To Analyse»
(`_DEFAULT_STATUS_LABEL`, ~стр. 950). Программно проверено: из 9 реальных стадий не
покрыта **ровно одна**`deploy-staging` (предпоследняя перед прод-деплоем, видна
чаще всего). Сам дефолт-«To Analyse» — мина на будущее: любая новая стадия даст ложный
«первый статус».
- **Дефект 2 (ложная картина при откате).** При rollback по конвейеру (напр. merge-gate
`deploy-staging → development`, ORCH-43; или REQUEST_CHANGES `review → development`)
верхние строки `✅ пройдено` (Код-ревью / Тестирование / Внедрение) НЕ снимаются, а внизу
снова `🔄 Разработка`. Абсурд: «Внедрение готово ✅, но идёт Разработка 🔄». Корень:
цикл рендера в `render_task_tracker` (~стр. 474505) выводит `✅`-строку для каждой
стадии `_TRACKER_STAGES`, у чьего агента есть завершённый прогон (`last_done`), без
учёта позиции стадии относительно текущей.
- **Дефект 3 (реальное занижение тоталов, не косметика).** Строка стадии берёт ПОСЛЕДНИЙ
прогон агента (`run = last_done.get(agent)`, ~стр. 475; `_stage_line`), теряя предыдущие
попытки. На задаче с ретраями метрики стадии занижены. Верифицировано на ORCH-069
(`task_id=54`, прод 09.06): developer = 3 прогона Σ $3.98 (карточка показывала ~$0.00 за
«Разработка»), reviewer = 3 Σ $2.10, tester = 2 Σ $1.03, deployer = 2 Σ $1.59. Источник
истины — таблица `agent_runs` (`cost_usd`, `input_tokens`, `output_tokens`,
`cache_read_tokens`, `cache_creation_tokens`, `started_at`/`finished_at`).
> **Замечание (факт кода, не противоречие).** Блок тоталов задачи (`💰`/`🔢`/`⏱ Агенты`)
> в текущем worktree уже суммирует ВСЕ прогоны (`render_task_tracker` ~стр. 388404).
> Заниженной остаётся **строка стадии** (`_stage_line` показывает только последний прогон).
> Требование G4/AC-5 формулируется на уровне строки стадии и инварианта сходимости тоталов
> с `SUM(agent_runs)` — реализация/архитектура подбора агрегата за архитектором.
## 2. Объём (scope)
### В объёме
- Покрытие `_STAGE_STATUS_LABEL` всеми ключами `STAGE_TRANSITIONS` из единого
программного источника истины (не «на глаз», не дублирующим списком).
- Осмысленный staging-лейбл для `deploy-staging`, согласованный с моделью статусов
ORCH-066/059.
- Нейтральный фолбэк для истинно неизвестной/битой стадии (вместо «To Analyse»).
- Отражение откатов: снятие `✅` со стадий ПОСЛЕ текущей позиции задачи.
- Метрика строки стадии = Σ всех `agent_runs` стадии (💰 стоимость / 🔢 токены / ⏱ время),
с сохранением сходимости тоталов задачи с `SUM(agent_runs)` по `task_id`.
- Тесты на полноту карты стадий, суммирование метрик, отражение отката; `CHANGELOG.md`.
### Вне объёма
- Изменение `STAGE_TRANSITIONS`, схемы БД, реестра `QG_CHECKS`/`check_*`, транспорта
нотификаций (`send/edit/delete_telegram`).
- Live-overlay ветки (Needs Input / Blocked / Rejected / Cancelled / Confirm Deploy /
Deploying / Monitoring) — работают, не трогаем.
- Архитектурное решение «как реализовать» (ordering-источник, форма агрегата) —
зона архитектора (`06-adr/`).
## 3. Заинтересованные стороны
- **Заказчик / приёмка:** Owner (homenet542), Слава (нашёл дефекты 08.06).
- **Затрагивается:** все наблюдатели карточек конвейера всех проектов (общий прод-инстанс,
self-hosting). Косметика карточки — для всех репо (orchestrator + enduro-trails).
## 4. Бизнес-требования (BR)
- **BR-1 (Деф.1, G1)** — `_STAGE_STATUS_LABEL` покрывает КАЖДЫЙ ключ `STAGE_TRANSITIONS`;
полнота гарантируется программно (итерация по единому источнику истины `src/stages.py`),
а не статичным списком. Для каждой реальной стадии `plane_status_label` возвращает
непустой осмысленный лейбл (не дефолт-«To Analyse», кроме реального `created`).
- **BR-2 (Деф.1, G1)** — `stage='deploy-staging'` → осмысленный staging-лейбл (напр.
«Deploying (staging)» / «⏳ Staging»), согласованный с моделью статусов ORCH-066/059.
- **BR-3 (Деф.1, G2)** — фолбэк для истинно неизвестной/битой стадии — нейтральный (напр.
«В работе» / stage capitalized), НЕ «To Analyse», чтобы будущая стадия не давала ложный
«первый статус». `plane_status_label` остаётся never-raise.
- **BR-4 (Деф.2, G3)** — при откате стадии карточка отражает ФАКТИЧЕСКУЮ текущую позицию:
с стадий ПОСЛЕ точки отката снимается `✅`; текущая стадия отрисовывается как активная
(`🔄`). Сценарий-эталон: после `deploy-staging → development` Разработка = `🔄`,
Тестирование/Внедрение — НЕ `✅`.
- **BR-5 (Деф.3, G4)** — метрика строки стадии = СУММА всех `agent_runs` этой стадии
(по `task_id` + агент стадии) по трём метрикам: 💰 `Σ cost_usd`, 🔢 `Σ (input + output +
cache_read + cache_creation)`, ⏱ `Σ (finished_at started_at)`. Тоталы задачи = суммы по
всем стадиям и попыткам, сходятся с `SUM(agent_runs)` по `task_id`.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (надёжность)** — `render_task_tracker` и `plane_status_label` остаются
**stateless / never-raise**: любая ошибка деградирует к безопасному выводу, конвейер
никогда не блокируется рендером карточки.
- **NFR-2 (совместимость / регресс)** — существующие метки и строки НЕ меняются: In Review
(brd-clock), Awaiting Deploy (`deploy`), Done, live-overlay ветки, строка `Подтверждение
BRD`, формат строк стадий/тоталов, эффорт-суффикс (ORCH-087). Изменение аддитивно.
- **NFR-3 (источник истины)** — полнота карты стадий выводится из `STAGE_TRANSITIONS`
программно; запрещено дублировать перечень стадий руками (анти-рассинхрон на будущее).
- **NFR-4 (self-hosting)** — изменения только в `src/notifications.py` + тесты + доки; без
правки `STAGE_TRANSITIONS`/схемы БД/QG; без рестарта прод-контейнера в рамках задачи.
## 6. Допущения и ограничения
- `tasks.stage` принимает строго значения-ключи `STAGE_TRANSITIONS` (включая
`deploy-staging`, `cancelled`). Это инвариант движка стадий.
- `cancelled` (ORCH-090) — системный терминал; его статус-лейбл уже рисуется live-overlay
(`_LIVE_BRANCH_LABELS['cancelled']`). Для offline-фолбэка `plane_status_label` он не
должен давать «To Analyse» (покрывается BR-3 нейтральным фолбэком; явный лейбл для
`cancelled` — на усмотрение архитектора, без конфликта с overlay).
- Источник метрик — `agent_runs`; стадия `deploy-staging` и `deploy` обслуживаются одним
агентом `deployer` — агрегат по агенту корректно покрывает обе (вопрос разнесения
staging/prod-прогонов по строкам — зона архитектора, не требование BRD).
- Telegram-ограничение 48ч на удаление сирот (ORCH-087) — вне объёма.
## 7. Критерии успеха
Карточка показывает корректный статус-заголовок на всех стадиях (включая `deploy-staging`),
не «лжёт» о пройденных стадиях после отката, и метрики строки стадии + тоталы сходятся с
`SUM(agent_runs)` по `task_id`. Полный регресс `pytest tests/ -q` зелёный. Детальные
PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- Рассинхрон карты стадий с `STAGE_TRANSITIONS` в будущем (митигируется NFR-3 + тест полноты).
- Регресс существующих меток/строк при правке цикла рендера (митигируется NFR-2 + тесты).
- Неверная точка отсчёта «позиции» стадии для отката (ordering) → неверное снятие `✅`.
Детали — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,112 @@
---
work_item: ORCH-091
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-091 — Карточка трекера: полнота карты статусов, отражение откатов, суммирование метрик по попыткам
Work Item: **ORCH-091** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода.
> Архитектурное обоснование/решения (выбор ordering-источника для отката, форма агрегата
> метрик, явный лейбл для `cancelled`) — задача архитектора (`06-adr/`).
## 1. Сводка изменения
Три точечные правки в `src/notifications.py` (рендер live-карточки ORCH-067), все аддитивные,
без изменения транспорта, схемы БД, `STAGE_TRANSITIONS` и `QG_CHECKS`:
1. **Полнота карты статусов (Деф.1).** `_STAGE_STATUS_LABEL` должен покрывать все ключи
`STAGE_TRANSITIONS` (источник истины — `src/stages.py`), добавить `deploy-staging`
осмысленный staging-лейбл; нейтральный фолбэк вместо «To Analyse» для неизвестной стадии.
2. **Отражение откатов (Деф.2).** Цикл рендера строк стадий перестаёт показывать `✅` для
стадий, расположенных ПОСЛЕ текущей позиции задачи в конвейере.
3. **Суммирование метрик стадии (Деф.3).** Строка стадии агрегирует ВСЕ `agent_runs` агента
стадии (Σ стоимость/токены/время) вместо последнего прогона; тоталы сходятся с `SUM`.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/notifications.py` | изменить: `_STAGE_STATUS_LABEL` (~940), `_DEFAULT_STATUS_LABEL` (~950), `plane_status_label` (~990), `render_task_tracker` (рендер строк стадий ~474505, агрегат метрик ~388404 / `_stage_line` ~445466) |
| `src/stages.py` | **только чтение** — импорт ключей `STAGE_TRANSITIONS` как источника истины для полноты карты (НЕ изменять) |
| `tests/test_tracker_status_line.py` | изменить/дополнить: полнота карты, staging-лейбл, нейтральный фолбэк |
| `tests/test_telegram_tracker.py` (или новый `tests/test_tracker_rollback_metrics.py`) | дополнить/создать: откат + суммирование метрик |
| `CHANGELOG.md` | изменить: запись ORCH-091 |
## 3. Функциональные требования
### FR-1 — Полнота `_STAGE_STATUS_LABEL` по `STAGE_TRANSITIONS` (BR-1)
- Для каждого ключа `STAGE_TRANSITIONS` (`created, analysis, architecture, development,
review, testing, deploy-staging, deploy, done, cancelled`) `plane_status_label` возвращает
непустой осмысленный лейбл.
- Полнота гарантируется **программно** от единого источника `src/stages.py::STAGE_TRANSITIONS`
(итерация/проверка пересечения ключей), а не статичным дублирующим списком (NFR-3).
- Сохранить спецветки `plane_status_label`: `analysis` + открытый brd-clock → `_IN_REVIEW_LABEL`
(без изменений).
### FR-2 — Staging-лейбл для `deploy-staging` (BR-2)
- `stage='deploy-staging'` → осмысленный лейбл (предлагается «Deploying (staging)» или
«⏳ Staging»; финальный текст согласует архитектор с моделью статусов ORCH-066/059).
- НЕ равен «To Analyse» и НЕ равен лейблу `deploy` (`⏸️ Awaiting Deploy …`).
### FR-3 — Нейтральный фолбэк (BR-3)
- Для строки `tasks.stage`, отсутствующей в карте (истинно неизвестная/битая/будущая
стадия), `plane_status_label` возвращает нейтральный лейбл (напр. «В работе» или
капитализированный stage), НЕ «To Analyse».
- `created` сохраняет осмысленный «To Analyse» как реальный первый статус.
- `plane_status_label` остаётся never-raise (любой сбой → безопасный лейбл).
### FR-4 — Отражение откатов в строках стадий (BR-4)
- В `render_task_tracker` строка `✅ <стадия>` НЕ отрисовывается для стадии, позиция которой
в конвейере ПОЗЖЕ текущего `tasks.stage`, даже если у её агента есть завершённый `agent_run`.
- Текущая стадия рисуется активной (`🔄`) по существующей логике `is_active_stage`.
- Стадии ДО текущей позиции (фактически пройденные) сохраняют `` со своими метриками.
- Источник порядка стадий — конвейер `STAGE_TRANSITIONS` (а не индекс в `_TRACKER_STAGES`);
конкретный механизм определения позиции — за архитектором. Учесть, что `deploy-staging`
отсутствует в `_TRACKER_STAGES` и `_STAGE_ACTIVE_AGENT` (обе стадии staging/deploy → агент
`deployer`): решение не должно ломать существующий рендер строки «Внедрение».
### FR-5 — Суммирование метрик стадии по попыткам (BR-5)
- Строка стадии показывает СУММУ по всем `agent_runs` агента стадии (по `task_id`):
- 💰 стоимость = `Σ cost_usd`;
- 🔢 токены = `Σ (input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens)`
(вход — через существующий `_input_total`; формат строки `<in>↓/<out>↑` сохранить);
- ⏱ время = `Σ _duration_seconds(started_at, finished_at)` по всем прогонам стадии.
- Тоталы задачи (💰/🔢/⏱ Агенты) остаются суммой по всем стадиям/попыткам и сходятся с
`SUM(agent_runs)` по `task_id` (инвариант сходимости).
- Модель/эффорт/счётчик «попытка N» в строке стадии сохранить (ORCH-087): при N≥2 показывать
актуально (модель — допускается из последнего прогона; согласовать с архитектором).
## 4. Изменения API
Нет. Эндпоинты не затрагиваются (рендер карточки вызывается из конвейера). Диагностический
блок `GET /queue` не меняется.
## 5. Изменения схемы БД
Нет. Используются существующие колонки `agent_runs` (`cost_usd`, `input_tokens`,
`output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `started_at`, `finished_at`,
`agent`, `task_id`, `exit_code`) и `tasks` (`stage`, `brd_review_started_at/ended_at`).
## 6. Требования к новым/изменённым QG checks
Нет. `QG_CHECKS` / `check_*` / `_parse_*` / `STAGE_TRANSITIONS` не затрагиваются. Изменение
касается только слоя индикации (карточка), не управляющего слоя конвейера.
## 7. Совместимость / регресс
- **Обратная совместимость:** все существующие метки и строки карточки неизменны (NFR-2):
In Review (brd-clock), Awaiting Deploy (`deploy`), Done, live-overlay ветки (Needs Input /
Blocked / Rejected / Cancelled / Confirm Deploy / Deploying / Monitoring), строка
`Подтверждение BRD`, формат строк стадий и тоталов, эффорт-суффикс.
- **Область раската:** косметика карточки для всех проектов общего инстанса (self-hosting +
enduro-trails). Чисто индикативный слой — управляющий конвейер не затронут.
- **Обратимость:** изменение docs/code-only в одном модуле; откат = revert PR. Kill-switch не
требуется (нет нового поведения конвейера; рендер never-raise деградирует безопасно).
- **Артефакты pipeline:** создаются/обновляются стандартные analysis-доки
(`01..04`); на стадии review — `12-review.md`; на testing — `13-test-report.md`. Новых
типов артефактов не вводится.
- **Анти-стейл/трассировка:** правится код, помеченный ORCH-067/ORCH-087 — перед правкой
читать их ADR (`docs/work-items/ORCH-067|ORCH-087/06-adr/`) и не ломать инварианты
(single-card, never-raise, разделение offline-ядра и live-overlay).

View File

@@ -0,0 +1,111 @@
---
work_item: ORCH-091
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-091 — Карточка трекера: статусы, откаты, метрики
Work Item: **ORCH-091** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Reviewer/тестер проверяет их буквально по файлам репозитория и
по выводу тестов.
---
## AC-1 — Полнота карты статусов по `STAGE_TRANSITIONS` (Деф.1 / BR-1)
**Условие:** для КАЖДОГО ключа `src/stages.py::STAGE_TRANSITIONS` `plane_status_label`
возвращает непустой осмысленный лейбл.
- **PASS:** параметризованный тест итерирует по всем ключам `STAGE_TRANSITIONS` и для каждого
(кроме реального `created`) получает непустой лейбл ≠ `_DEFAULT_STATUS_LABEL`-«To Analyse».
Полнота карты выведена программно из `STAGE_TRANSITIONS`, а не статичным списком в тесте.
- **FAIL:** хотя бы одна стадия из `STAGE_TRANSITIONS` отдаёт «To Analyse» (кроме `created`);
либо полнота проверяется захардкоженным списком, не связанным с `STAGE_TRANSITIONS`.
---
## AC-2 — Staging-лейбл для `deploy-staging` (Деф.1 / BR-2)
**Условие:** `stage='deploy-staging'` даёт осмысленный staging-лейбл.
- **PASS:** `plane_status_label` для строки со `stage='deploy-staging'` возвращает осмысленный
staging-лейбл (напр. «Deploying (staging)» / «⏳ Staging»), отличный от «To Analyse» и от
лейбла стадии `deploy` (`⏸️ Awaiting Deploy …`).
- **FAIL:** возвращает «To Analyse», пустую строку, либо лейбл, неотличимый от `deploy`.
---
## AC-3 — Нейтральный фолбэк для неизвестной стадии (Деф.1 / BR-3)
**Условие:** истинно неизвестная/битая стадия → нейтральный фолбэк, never-raise.
- **PASS:** для строки с заведомо несуществующим `stage` (напр. `"__bogus__"`)
`plane_status_label` возвращает нейтральный лейбл (НЕ «To Analyse») и не бросает исключение;
для битого входа (None/нет ключа `stage`) тоже не падает.
- **FAIL:** неизвестная стадия даёт «To Analyse»; либо функция бросает исключение на
битом/неизвестном входе.
---
## AC-4 — Отражение отката в строках стадий (Деф.2 / BR-4)
**Условие:** после rollback `deploy-staging → development` карточка показывает фактическую
позицию.
- **PASS:** для задачи с завершёнными прогонами reviewer/tester/deployer, но текущим
`stage='development'`, `render_task_tracker` рисует Разработку как активную (`🔄`), а
Тестирование и Внедрение — НЕ как `✅ пройдено`. Стадии до development (Анализ, Архитектура)
остаются `✅`.
- **FAIL:** карточка одновременно показывает `✅ Внедрение/Тестирование/Код-ревью` и
`🔄 Разработка` (картина «Внедрение готово ✅, но идёт Разработка»).
---
## AC-5 — Суммирование метрик стадии по попыткам (Деф.3 / BR-5)
**Условие:** стадия с N попытками показывает СУММУ метрик по всем N `agent_runs`.
- **PASS:** для стадии с N>1 `agent_runs` строка стадии показывает Σ времени, Σ токенов
(`input+output+cache_read+cache_creation`) и Σ стоимости по всем N прогонам. На фикстуре
по образцу ORCH-069 (developer: 3 прогона, суммарно ≈ $3.98) строка «Разработка» отражает
≈ $3.98, а не стоимость последнего прогона. Тоталы задачи (💰/🔢/⏱ Агенты) сходятся с
`SUM(agent_runs)` по `task_id` (по стоимости, токенам, длительностям).
- **FAIL:** строка стадии показывает метрики только последнего прогона (занижение); либо
тоталы задачи не сходятся с `SUM(agent_runs)`.
---
## AC-6 — Регресс существующих меток (NFR-2)
**Условие:** существующие индикаторы карточки не изменены.
- **PASS:** In Review (brd-clock, `_IN_REVIEW_LABEL`), Awaiting Deploy (`deploy`), Done,
live-overlay ветки (Needs Input / Blocked / Rejected / Cancelled / Confirm Deploy /
Deploying / Monitoring), строка `Подтверждение BRD`, формат строк стадий/тоталов и
эффорт-суффикс — рендерятся как прежде; существующие тесты карточки зелёные.
- **FAIL:** изменён текст/формат любой из перечисленных меток; падает существующий тест
карточки.
---
## AC-7 — Тесты и документация (G/AC-7)
**Условие:** добавлены тесты и обновлена документация.
- **PASS:** `pytest tests/ -q` зелёный; добавлены тесты на полноту карты стадий (AC-1/2/3),
суммирование метрик (AC-5), отражение отката (AC-4); `CHANGELOG.md` содержит запись
ORCH-091; `render_task_tracker`/`plane_status_label` остаются never-raise.
- **FAIL:** регресс `pytest tests/ -q`; отсутствует любой из обязательных новых тестов; не
обновлён `CHANGELOG.md`.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 |
| AC-2 | BR-2 / FR-2 |
| AC-3 | BR-3 / FR-3 |
| AC-4 | BR-4 / FR-4 |
| AC-5 | BR-5 / FR-5 |
| AC-6 | NFR-2 (регресс) |
| AC-7 | NFR-1 + цель G/AC-7 (тесты, доки, never-raise) |

View File

@@ -0,0 +1,76 @@
work_item: ORCH-091
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
title: "Карточка трекера: полнота статусов, отражение откатов, суммирование метрик по попыткам"
framework: pytest
scope: >
Юнит-покрытие чистых функций src/notifications.py (plane_status_label,
render_task_tracker) и интеграция рендера от состояния БД (tasks + agent_runs).
Вне покрытия: транспорт Telegram (send/edit/delete), live-overlay ветки (сеть),
STAGE_TRANSITIONS/QG/схема БД (не трогаются).
notes: >
Полнота карты статусов должна выводиться программно из src/stages.py::STAGE_TRANSITIONS
(а не из захардкоженного списка стадий). Метрики читаются из таблицы agent_runs:
cost_usd, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
started_at/finished_at. Фикстура-эталон сумм — ORCH-069 (developer: 3 прогона ≈ $3.98).
Полный регресс pytest tests/ -q должен оставаться зелёным; существующие тесты карточки
(test_tracker_status_line, test_telegram_tracker, test_tracker_effort_time) не должны
ломаться.
tests:
- id: TC-01
type: unit
description: "Полнота: для каждого ключа STAGE_TRANSITIONS (программная итерация) plane_status_label возвращает непустой лейбл, не 'To Analyse' (кроме created). AC-1"
module: tests/test_tracker_status_line.py
expected: PASS
- id: TC-02
type: unit
description: "stage='deploy-staging' -> осмысленный staging-лейбл, отличный от 'To Analyse' и от лейбла стадии 'deploy'. AC-2"
module: tests/test_tracker_status_line.py
expected: PASS
- id: TC-03
type: unit
description: "Истинно неизвестная стадия ('__bogus__') -> нейтральный фолбэк (не 'To Analyse'); never-raise на битом/None входе. AC-3"
module: tests/test_tracker_status_line.py
expected: PASS
- id: TC-04
type: unit
description: "Регресс ветки plane_status_label: analysis + открытый brd-clock -> In Review; deploy -> Awaiting Deploy; done -> Done; created -> To Analyse. AC-6"
module: tests/test_tracker_status_line.py
expected: PASS
- id: TC-05
type: integration
description: "Откат deploy-staging->development: задача stage='development' с завершёнными прогонами reviewer/tester/deployer -> Разработка активна (🔄), Тестирование/Внедрение НЕ как ✅; Анализ/Архитектура остаются ✅. AC-4"
module: tests/test_tracker_rollback_metrics.py
expected: PASS
- id: TC-06
type: integration
description: "Суммирование метрик стадии: developer с 3 agent_runs (фикстура ORCH-069) -> строка 'Разработка' показывает Σ стоимости ≈ $3.98, Σ токенов, Σ времени, а не последний прогон. AC-5"
module: tests/test_tracker_rollback_metrics.py
expected: PASS
- id: TC-07
type: integration
description: "Сходимость тоталов: тоталы карточки (💰/🔢/⏱ Агенты) равны SUM(agent_runs) по task_id (cost_usd, токены, длительности) при наличии ретраев. AC-5"
module: tests/test_tracker_rollback_metrics.py
expected: PASS
- id: TC-08
type: integration
description: "render_task_tracker never-raise: битые/частичные строки tasks/agent_runs (NULL timestamps, отсутствующий stage) -> возвращает строку-фолбэк без исключения. NFR-1 / AC-7"
module: tests/test_tracker_rollback_metrics.py
expected: PASS
- id: TC-09
type: unit
description: "Регресс существующих строк карточки: формат строк стадий, эффорт-суффикс (ORCH-087), строка 'Подтверждение BRD', блок тоталов — без изменений. AC-6"
module: tests/test_telegram_tracker.py
expected: PASS

View File

@@ -0,0 +1,193 @@
---
work_item: ORCH-091
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-001: Карточка трекера — полнота карты статусов, отражение откатов, суммирование метрик по попыткам
Work Item: **ORCH-091** — три верифицированных дефекта live-карточки (`src/notifications.py`)
Стадия: **architecture**
Сквозная регистрация: **N/A, локальное решение задачи** (затронут ровно один модуль
индикативного слоя `src/notifications.py`; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` /
схема БД / транспорт нотификаций — не трогаются; новый компонент/стадия/гейт не вводятся).
## Статус
Accepted
## Контекст
Live Telegram-карточка (ORCH-067/087, единственная карточка на задачу,
`render_task_tracker` / `plane_status_label`) — основной канал наблюдения за конвейером.
BRD/ТЗ объединяют три дефекта, сверенные по коду и БД прода (09.06):
- **Деф.1 — застрявший заголовок «To Analyse».** `_STAGE_STATUS_LABEL`
(`src/notifications.py:940`) содержит 8 ключей (`created/analysis/architecture/development/
review/testing/deploy/done`), а `tasks.stage` принимает ключи `STAGE_TRANSITIONS`
(`src/stages.py:12`) — среди них **`deploy-staging`** (не покрыт) и **`cancelled`** (ORCH-090,
не покрыт). `plane_status_label` (`:1009`) делает `.get(stage, _DEFAULT_STATUS_LABEL)`
непокрытая стадия отдаёт дефолт **«To Analyse»** (`:950`). Из 10 реальных стадий не покрыты
две; дефолт-«To Analyse» — ещё и мина: любая новая стадия даст ложный «первый статус».
- **Деф.2 — ложная картина при откате.** Цикл рендера (`:474505`) выводит `✅`-строку для
каждой стадии `_TRACKER_STAGES`, у чьего агента есть завершённый прогон (`last_done`), **без
учёта позиции стадии относительно текущей**. После отката (`deploy-staging → development`,
ORCH-43; `review → development`, REQUEST_CHANGES) карточка показывает «✅ Внедрение … +
🔄 Разработка» — абсурд.
- **Деф.3 — занижение метрик строки стадии.** `_stage_line` берёт `run = last_done.get(agent)`
(`:475`) — ПОСЛЕДНИЙ прогон, теряя предыдущие попытки. Верифицировано на ORCH-069 (task 54):
developer 3 прогона Σ $3.98, карточка показывала ~$0.00. Блок тоталов задачи (`:388404`) уже
суммирует все прогоны — заниженной остаётся **строка стадии**.
Ключевая структурная сложность (флаг ТЗ §FR-4): `_TRACKER_STAGES` — 6 строк; стадии
`deploy-staging` и `deploy` **схлопнуты** в одну строку «Внедрение» (`stage_key="deploy"`,
агент `deployer`). `_STAGE_ACTIVE_AGENT` тоже не содержит `deploy-staging`. Любое решение по
порядку/позиции обязано не сломать этот сложившийся рендер строки «Внедрение».
## Решение
### Сводка
Три аддитивные правки в `src/notifications.py`, минимизирующие регресс-поверхность:
1. **Полнота карты** — расширить `_STAGE_STATUS_LABEL` недостающими ключами
(`deploy-staging`, `cancelled`); заменить runtime-фолбэк с «To Analyse» на **нейтральный**
(капитализированное имя стадии). Полнота гарантируется **тестом**, итерирующим
`STAGE_TRANSITIONS.keys()` (единый источник истины), а не дублирующим списком.
2. **Отражение откатов** — ввести позицию стадии в конвейере из **порядка `STAGE_TRANSITIONS`**
и гасить `✅`-строку для стадий ПОЗЖЕ текущей позиции. Нормализация `deploy-staging → deploy`
применяется **только** к вычислению текущей позиции (для гейта подавления), логика
`is_active_stage`**без изменений** (нулевой регресс активного рендера).
3. **Суммирование метрик**`_stage_line` агрегирует ВСЕ `agent_runs` агента стадии
(теми же per-run-аккумуляторами, что и блок тоталов) → строгая сходимость с `SUM(agent_runs)`.
Все функции остаются **stateless / never-raise**; любая ошибка деградирует к безопасному
выводу (старое поведение).
### D1 — Полнота `_STAGE_STATUS_LABEL` + нейтральный фолбэк (Деф.1 / FR-1,2,3 / AC-1,2,3)
- Расширить `_STAGE_STATUS_LABEL`, добавив **все** недостающие ключи `STAGE_TRANSITIONS`:
- `"deploy-staging": "Deploying (staging)"` — осмысленный staging-лейбл, согласованный с
моделью статусов ORCH-066/059: **plain-стиль** активной стадии (как `Analysis`/`Testing`,
без `⏸️`-маркера паузы), **отличен** от «To Analyse» и от лейбла `deploy`
(«⏸️ Awaiting Deploy — ожидание Confirm Deploy»). Суффикс «(staging)» снимает коллизию с
prod-overlay «Deploying» (`_LIVE_BRANCH_LABELS['deploying']`). (FR-2 / AC-2.)
- `"cancelled": "Cancelled"` — offline-база для системного терминала ORCH-090. Совпадает с
overlay-лейблом `_LIVE_BRANCH_LABELS['cancelled']` ("Cancelled") → нет конфликта precedence
в `_card_status_label`; offline-путь больше не отдаёт «To Analyse» для отменённой задачи.
- **Runtime-фолбэк** в `plane_status_label`: вместо `_STAGE_STATUS_LABEL.get(stage,
_DEFAULT_STATUS_LABEL)` использовать **нейтральный** лейбл для отсутствующего ключа —
капитализированное имя стадии (напр. `stage.replace("-", " ").title()` → «Deploy Staging»),
с финальным безопасным дефолтом при пустом/битом входе. `created` сохраняет осмысленный
«To Analyse» как реальный первый статус (он **остаётся явным ключом** в карте). (FR-3 / AC-3.)
- `_DEFAULT_STATUS_LABEL` сохраняется как имя для `created` и для безопасной деградации на
истинно-битом входе (`None`/нет ключа `stage`); он **перестаёт** быть фолбэком для «известная
стадия, но нет лейбла» — этот путь теперь нейтрально-капитализированный.
- Спецветка `analysis` + открытый brd-clock → `_IN_REVIEW_LABEL` — **без изменений** (NFR-2).
- **Программная полнота (NFR-3)** обеспечивается тестом, который итерирует
`from src.stages import STAGE_TRANSITIONS` и для каждого ключа (кроме `created`) утверждает
непустой лейбл `≠ _DEFAULT_STATUS_LABEL`. Новая стадия без курируемого лейбла → красный тест.
**Запрещено** в самом модуле автогенерировать лейблы из имён стадий (теряется человеческая
осмысленность) — карта остаётся курируемой, тест лишь гарантирует её покрытие.
### D2 — Отражение откатов: позиция из `STAGE_TRANSITIONS` (Деф.2 / FR-4 / AC-4)
- Ввести в `src/notifications.py` (НЕ в `src/stages.py` — он read-only по ТЗ) лёгкий
индекс-хелпер от **единого источника порядка** `STAGE_TRANSITIONS`:
```python
from .stages import STAGE_TRANSITIONS
_PIPELINE_ORDER = list(STAGE_TRANSITIONS.keys()) # created..done, cancelled
def _pipeline_pos(stage): # never-raise
try:
return _PIPELINE_ORDER.index(stage)
except (ValueError, TypeError):
return len(_PIPELINE_ORDER) # unknown -> «далёкое будущее»
```
- **Нормализация staging→deploy ТОЛЬКО для текущей позиции:**
`effective_stage = "deploy" if stage == "deploy-staging" else stage`;
`current_pos = _pipeline_pos(effective_stage)`. Это отражает, что строка «Внедрение»
представляет фазу deployer'а (staging+prod) как одну — иначе при `stage='deploy-staging'`
строка «Внедрение» (`stage_key="deploy"`, pos 7) была бы ошибочно подавлена (6 < 7).
- **Гейт подавления:** ветка `elif run is not None` (рендер `✅ <стадия>`) срабатывает **только
если** `current_pos >= _pipeline_pos(stage_key)`. Иначе (прогон есть, но стадия ПОЗЖЕ
текущей — откат) строка не выводится. Стадии ДО/НА текущей позиции сохраняют `` (фактически
пройденные).
- **`is_active_stage` — без изменений** (использует «сырой» `stage`, `_STAGE_ACTIVE_AGENT`,
`has_inflight`). Это даёт нулевой регресс активного/«just-finished snapshot» рендера (NFR-2):
при `stage='deploy-staging'` строка «Внедрение» ведёт себя как сегодня (✅ при завершённом
staging-прогоне, иначе ничего) — нормализация затрагивает лишь гейт подавления, не активность.
- Источник позиции — **порядок `STAGE_TRANSITIONS`**, а не индекс в `_TRACKER_STAGES` (NFR-3,
FR-4): добавление/перестановка стадий в движке автоматически корректирует подавление.
- **Условие 🔄 после отката (AC-4):** строка «Разработка» рисуется активной (`🔄`) существующей
логикой `is_active_stage`, которой нужен `has_inflight or run is None`. Реальное
пост-откатное состояние — reviewer/merge-gate ставит developer-job → launcher создаёт строку
`agent_runs` c `finished_at IS NULL` (in-flight) → `🔄`. Фикстура AC-4 обязана содержать
этот in-flight developer-прогон (так выглядит прод после отката). Наш фикс ортогонально
снимает ложные `` со стадий review/testing/Внедрение.
### D3 — Суммирование метрик строки стадии (Деф.3 / FR-5 / AC-5)
- `_stage_line` принимает **список прогонов** агента стадии (готовый
`agent_runs_by_agent.get(agent, [])`) вместо одного `run` и агрегирует **теми же
per-run-формулами, что блок тоталов задачи** (`:388404`):
- 💰 `cost = Σ float(cost_usd or 0)`;
- 🔢 `in = Σ _input_total(usage)` (= Σ(input+cache_read+cache_creation)),
`out = Σ int(output_tokens or 0)`; формат `<in>↓/<out>↑` сохранён;
- ⏱ `dur = Σ _duration_seconds(started_at, finished_at)` (None-прогоны пропускаются, как в
тоталах).
- **Инвариант сходимости:** блок тоталов и строки стадий теперь аккумулируют **по одному и тому
же множеству строк `agent_runs`** и **одними формулами**; каждый агент привязан ровно к одной
строке `_TRACKER_STAGES` (analyst/architect/developer/reviewer/tester/deployer). Поэтому
Σ(показанных+подавленных строк стадий) ≡ тоталы задачи ≡ `SUM(agent_runs)` по `task_id`
(по стоимости/токенам/времени). Подавлённые откатом строки (D2) не рисуются, но их прогоны
**по-прежнему** входят в тоталы — это и есть намеренная семантика отката, инвариант AC-5 не
нарушается (тоталы считают всё; строка стадии — Σ своих прогонов).
- **Модель/эффорт/«попытка N» (ORCH-087, FR-5):** агрегируются метрики, но модель/эффорт
берутся из **последнего** прогона агента (`agent_runs` упорядочены `id ASC` → последний
элемент списка) через существующие `short_model_name` / `_run_effort`; счётчик попыток для
активной строки (`len(agent_runs)`) — без изменений. Формат строки байт-в-байт сохранён (NFR-2).
## Альтернативы
- **Автогенерация лейблов из имён стадий (полностью программная карта)** — отвергнуто: теряется
человеческая осмысленность («deploy-staging» → не лучше «Deploying (staging)»); курируемая
карта + тест полноты дают и читаемость, и анти-рассинхрон.
- **Нормализация `deploy-staging→deploy` во ВСЁМ цикле (включая `is_active_stage`)** —
отвергнуто как первичное решение: меняет активный рендер строки «Внедрение» на стадии
`deploy-staging` (риск регресса существующих тестов, NFR-2/AC-6). Нормализация ограничена
гейтом подавления — минимальная поверхность.
- **Позиция стадии из индекса `_TRACKER_STAGES`** — отвергнуто: `_TRACKER_STAGES` не содержит
`deploy-staging`/`cancelled` и не является источником истины о порядке конвейера (нарушает
NFR-3). Источник — `STAGE_TRANSITIONS`.
- **Изменение `_TRACKER_STAGES`/`_STAGE_ACTIVE_AGENT` (добавить deploy-staging-строку)** —
отвергнуто: вне объёма BRD (формат строк неизменен, NFR-2), расширяет регресс-поверхность.
## Последствия
- **+** Заголовок честен на всех стадиях (вкл. `deploy-staging`, `cancelled`); будущая стадия
не даёт ложный «To Analyse» (нейтральный фолбэк + тест полноты).
- **+** Карточка не «лжёт» после отката: `` снимается со стадий ПОЗЖЕ текущей позиции.
- **+** Метрики строки стадии = Σ всех попыток; строгая сходимость с `SUM(agent_runs)`.
- **+** Источник порядка/полноты — `STAGE_TRANSITIONS` (программно), анти-рассинхрон на будущее.
- **** Новая read-only связь `notifications.py → stages.STAGE_TRANSITIONS` (порядок+ключи).
Митигейшн: импорт ключей, `stages.py` не изменяется (разрешено ТЗ §2); `_pipeline_pos`
never-raise (unknown → «далёкое будущее» = старое поведение, ✅ не пере-подавляется).
- **** При `stage='deploy-staging'` строка «Внедрение» может показать `` по завершённому
staging-прогону (до prod-деплоя). Это **сохранённое** поведение (NFR-2), не регресс и не
дефект по BRD; нормализация затрагивает только подавление, не активность.
- **Откат:** изменение docs/code-only в одном модуле + тесты → `git revert` PR. Kill-switch не
требуется (нет нового поведения конвейера; рендер never-raise деградирует безопасно).
## Ссылки
- BRD: `docs/work-items/ORCH-091/01-brd.md`
- TRZ: `docs/work-items/ORCH-091/02-trz.md`
- Acceptance: `docs/work-items/ORCH-091/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-091/10-tech-risks.md`
- Сверено по коду: `src/notifications.py` (`_STAGE_STATUS_LABEL:940`, `_DEFAULT_STATUS_LABEL:950`,
`plane_status_label:990`, `render_task_tracker:333`, `_stage_line:445`, `_TRACKER_STAGES:233`,
`_STAGE_ACTIVE_AGENT:248`, totals `:388-404`), `src/stages.py::STAGE_TRANSITIONS:12`,
`src/usage.py::_input_total:348`.
- Инварианты, которые НЕЛЬЗЯ ломать (прочитаны перед правкой): ORCH-067/ORCH-087
(`docs/work-items/ORCH-067|ORCH-087/06-adr/`) — single-card, never-raise, разделение
offline-ядра и live-overlay; ORCH-090 (`adr-0026`) — терминал `cancelled`.

View File

@@ -0,0 +1,36 @@
---
work_item: ORCH-091
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-091 — Карточка трекера (статусы, откаты, метрики)
Work Item: **ORCH-091** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | Регресс существующих меток/строк при правке цикла рендера (In Review, Awaiting Deploy, Done, эффорт-суффикс, формат строк/тоталов) | Сред. | Сред. | `is_active_stage` не трогаем; нормализация только в гейте подавления (D2); формат `_stage_line` байт-в-байт; зелёные `tests/test_tracker_*` + `test_telegram_tracker` (AC-6). |
| TR-2 | Рассинхрон карты статусов с `STAGE_TRANSITIONS` в будущем (новая стадия без лейбла) | Сред. | Низ. | Полнота — тест по `STAGE_TRANSITIONS.keys()` (NFR-3); нейтральный фолбэк вместо «To Analyse» (D1) → даже без лейбла не «лжёт». |
| TR-3 | Неверная точка отсчёта позиции стадии → неверное снятие/сохранение `✅` (особенно схлопывание `deploy-staging`/`deploy` в строку «Внедрение») | Сред. | Сред. | Позиция из порядка `STAGE_TRANSITIONS`; нормализация `deploy-staging→deploy` только для current-pos (D2); сценарные тесты отката `deploy-staging→development` и `review→development` (AC-4). |
| TR-4 | Расхождение метрик строки стадии с тоталами задачи (двойной/потерянный учёт) | Низ. | Сред. | Строка и тоталы используют ОДНИ формулы (`_input_total`/`_duration_seconds`/`cost_usd`) над ОДНИМ множеством `agent_runs`; тест сходимости Σ(строки) == `SUM(agent_runs)` по `task_id` (AC-5). |
| TR-5 | Исключение в `render_task_tracker`/`plane_status_label` блокирует индикацию | Низ. | Сред. | Контракт never-raise сохранён; `_pipeline_pos` never-raise (unknown → «далёкое будущее» = старое поведение); деградация к безопасному выводу (NFR-1/AC-3,7). |
| TR-6 | Новая import-связь `notifications.py → stages` вводит цикл импорта | Низ. | Низ. | `stages.py` — лист без обратных зависимостей на `notifications`; импорт ключей словаря, не функций; `stages.py` не изменяется (ТЗ §2). |
| TR-7 | Фикстура AC-4 без in-flight developer-прогона → строка «Разработка» не `🔄` | Низ. | Низ. | ADR D2 фиксирует: пост-откатный `🔄` требует строки `agent_runs` c `finished_at IS NULL`; тест-план обязан включать такой прогон (реальное прод-состояние после relaunch). |
## Сводный вывод
Доминирующий класс — **риски регресса индикативного слоя** (TR-1/TR-3) и **сходимости метрик**
(TR-4). Все смягчаются тестами и минимальной поверхностью правок (один модуль, без затрагивания
`STAGE_TRANSITIONS`/`QG_CHECKS`/схемы БД/транспорта). Эскалация `arch:major-change` **не нужна**:
изменение локально, обратимо `git revert`, never-raise, kill-switch не требуется. Возврат в анализ
**не требуется** — BRD/ТЗ полны и реализуемы без нарушения принципов. Остаточный риск для
прод-конвейера (self-hosting) — **низкий**: слой чисто индикативный, управляющий конвейер
(стадии/гейты/очередь) не затрагивается, рендер деградирует безопасно.

View File

@@ -0,0 +1,85 @@
---
verdict: APPROVED
work_item: ORCH-091
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-09
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-091
version: 1
---
# Review ORCH-091
## Summary
PR закрывает три верифицированных дефекта рендера live-карточки трекера
(`src/notifications.py`, ORCH-067/087): (Д1) застрявший заголовок «To Analyse» из-за неполноты
`_STAGE_STATUS_LABEL`; (Д2) ложные `✅`-строки стадий после отката конвейера; (Д3) занижение
метрик строки стадии (последний прогон вместо суммы попыток). Изменение **аддитивное,
indication-only, never-raise**: затронут ровно один src-модуль (`src/notifications.py`),
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / транспорт нотификаций / схема БД — не тронуты.
Проверка по четырём осям пройдена:
- **Соответствие ТЗ** — все FR-1…FR-5 реализованы; AC-1…AC-7 выполнены буквально.
- **Соответствие ADR** — реализация 1:1 с `06-adr/ADR-001` (D1/D2/D3); read-only-связь
`notifications.py → stages.STAGE_TRANSITIONS` оформлена как указано; `is_active_stage` не тронут.
- **Качество кода** — `_pipeline_pos` / `_neutral_stage_label` never-raise; докстринги и
трассировочные ORCH-091-комментарии присутствуют; полный регресс зелёный (1370).
- **Документация** — обновлена в том же PR (см. ниже).
### Проверенные инварианты
- **Трассировка ORCH-067/087** (правка маркированного кода): инварианты single-card, never-raise,
разделение offline-ядра/live-overlay сохранены — подтверждено ADR (прочитаны перед правкой) и
зелёным регрессом `test_tracker_status_line.py`.
- **Терминал `cancelled` (ORCH-090, adr-0026)**: добавлен offline-лейбл `cancelled → "Cancelled"`,
совпадает с overlay `_LIVE_BRANCH_LABELS['cancelled']` → нет конфликта precedence.
- **Полнота карты от источника истины** — тест `test_orch091_tc01_*` параметризован по
`STAGE_TRANSITIONS.keys()` (не статичный список) → NFR-3 выполнен.
- **Сходимость метрик** — `_stage_line` использует те же per-run-формулы, что блок тоталов;
тест `test_tc07_*` проверяет сходимость с `SUM(agent_runs)` и Σ(строк стадий) ≡ тоталы на done.
- **Нормализация `deploy-staging → deploy`** ограничена гейтом подавления (не затрагивает
активный рендер строки «Внедрение») — подтверждено `test_tc05_deploy_staging_keeps_deployer_row`.
- **Отсутствие циркулярного импорта** — `import src.notifications; import src.stages` → OK.
- **ORCH-079 (обзорные доки)** — `README.md` «Известные ограничения» НЕ содержит пункта о
дефектах карточки трекера → закрывать/обновлять нечего; gate не нарушен.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have (не блокирует)
- [ ] `from .stages import STAGE_TRANSITIONS` размещён в середине модуля (`src/notifications.py`
после `_STAGE_ACTIVE_AGENT`, с `# noqa: E402`). Размещение намеренно и документировано
комментарием, циркулярного импорта нет; вынос в шапку модуля — косметическая необязательная
уборка на будущее.
## Документация
Обновлена в том же PR (golden source синхронен с кодом):
- **`CHANGELOG.md`** — запись ORCH-091 (`fix`) с описанием трёх дефектов, тестов и отката. ✅
- **`docs/architecture/internals.md`** §7 — описаны откат-подавление `✅`, суммирование метрик
и полнота `_STAGE_STATUS_LABEL`. ✅
- **`docs/architecture/README.md`** (Notifications / Live-tracker) — добавлен блок «ORCH-091
(индикация-only)» с тремя правками и ссылкой на ADR. ✅ (внесено архитектором, присутствует в PR)
- **ADR** — `docs/work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md`
(status: accepted), решения D1/D2/D3, альтернативы, последствия. ✅
- **`README.md` (root)** — обновление не требуется: ни один пункт «Известные ограничения» не
закрывается данным PR (ORCH-079 gate соблюдён). ✅
Изменения `src/` сопровождены соответствующим обновлением документации → ось «документация»
пройдена; основание для `REQUEST_CHANGES` по этой оси отсутствует.
## Вердикт
`APPROVED` — нет findings уровня P0/P1; код, тесты и документация согласованы; инварианты
ORCH-067/087/090 и NFR-2/NFR-3 сохранены; полный регресс `pytest tests/ -q` зелёный (1370 passed).

View File

@@ -0,0 +1,98 @@
---
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
work_item: ORCH-091
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-09
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-091
---
# Test Report — ORCH-091
BUG: заголовок-строка live-карточки трекера застревает на «To Analyse» на
`stage=deploy-staging` (нет ключа в `_STAGE_STATUS_LABEL`) + ложные `✅`-строки после
отката + занижение метрик строки стадии (последний прогон вместо суммы попыток).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-091-bug-to-analyse-stage-deploy-st`
- Ветка: `feature/ORCH-091-bug-to-analyse-stage-deploy-st`
- Дата: 2026-06-09
## Предусловия
- Вердикт reviewer (`12-review.md`): **APPROVED** (P0=0, P1=0) ✅
- Тесты прогнаны из worktree ветки задачи (не из общего `/repos/orchestrator`) ✅
## Smoke API (read-only)
| Эндпоинт | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | 200, активные задачи отдаются (ORCH-091 = `testing`) — OK |
| `GET /queue` | 200; блок `serial_gate` присутствует (ORCH-088) ✅; блок `auto_labels` присутствует (ORCH-089) ✅ |
`serial_gate.per_repo.orchestrator.active_task = ORCH-091/testing`, регресс смока отсутствует.
## Покрытие тест-плана (`04-test-plan.yaml`) ↔ критерии приёмки (`03-acceptance-criteria.md`)
| TC ID | Описание | AC | Тест(ы) | Результат |
|-------|----------|----|---------|-----------|
| TC-01 | Полнота карты: каждый ключ `STAGE_TRANSITIONS` (программная итерация) → непустой лейбл ≠ «To Analyse» (кроме `created`) | AC-1 | `test_tracker_status_line::test_orch091_tc01_every_stage_has_meaningful_label[*]` (9 параметров) + `test_orch091_tc01_created_stays_to_analyse` | PASS |
| TC-02 | `stage='deploy-staging'` → осмысленный staging-лейбл ≠ «To Analyse» и ≠ лейбла `deploy` | AC-2 | `test_tracker_status_line::test_orch091_tc02_deploy_staging_label` | PASS |
| TC-03 | Неизвестная стадия (`__bogus__`) → нейтральный фолбэк (не «To Analyse»); never-raise на битом/None входе | AC-3 | `test_orch091_tc03_unknown_stage_neutral_not_to_analyse` + `test_orch091_tc03_cancelled_offline_label` + `test_tc09c_plane_status_label_never_raises` | PASS |
| TC-04 | Регресс ветвей `plane_status_label`: analysis+brd-clock→In Review; deploy→Awaiting Deploy; done→Done; created→To Analyse | AC-6 | `test_tracker_status_line::test_tc06_stage_to_plane_status[*]` (8) + `test_tc07_in_review_from_brd_clock` + `test_tc08_awaiting_deploy_offline` | PASS |
| TC-05 | Откат `deploy-staging→development`: Разработка активна (`🔄`), Тестирование/Внедрение НЕ `✅`; Анализ/Архитектура остаются `✅` | AC-4 | `test_tracker_rollback_metrics::test_tc05_rollback_suppresses_later_stage_checkmarks` + `test_tc05_forward_progress_keeps_earlier_checkmarks` + `test_tc05_deploy_staging_keeps_deployer_row` | PASS |
| TC-06 | Суммирование метрик: developer с 3 `agent_runs` (фикстура ORCH-069) → строка «Разработка» = Σ стоимости ≈ $3.98, Σ токенов, Σ времени | AC-5 | `test_tracker_rollback_metrics::test_tc06_stage_line_sums_all_developer_runs` | PASS |
| TC-07 | Сходимость тоталов карточки (💰/🔢/⏱ Агенты) с `SUM(agent_runs)` по `task_id` при ретраях | AC-5 | `test_tc07_totals_converge_with_sum_agent_runs` + `test_tc07_sum_of_stage_lines_equals_totals_on_done` | PASS |
| TC-08 | `render_task_tracker` never-raise: NULL timestamps / отсутствующий stage → строка-фолбэк без исключения | AC-7 / NFR-1 | `test_tc08_render_survives_null_timestamps_and_runs` + `test_tc08_render_survives_bogus_stage` | PASS |
| TC-09 | Регресс строк карточки: формат строк стадий, эффорт-суффикс (ORCH-087), «Подтверждение BRD», блок тоталов — без изменений | AC-6 | `test_telegram_tracker.py` + `test_tracker_effort_time.py` (эффорт по ролям, capped review-time, done-time labels) — все зелёные | PASS |
Каждый TC из `04-test-plan.yaml` выполнен и сопоставлен с критериями `03-acceptance-criteria.md`.
## Вывод pytest
```
$ cd /repos/_wt/orchestrator/feature_ORCH-091-bug-to-analyse-stage-deploy-st
$ pytest tests/ -v --tb=short
tests/test_tracker_rollback_metrics.py::test_tc05_rollback_suppresses_later_stage_checkmarks PASSED
tests/test_tracker_rollback_metrics.py::test_tc05_forward_progress_keeps_earlier_checkmarks PASSED
tests/test_tracker_rollback_metrics.py::test_tc05_deploy_staging_keeps_deployer_row PASSED
tests/test_tracker_rollback_metrics.py::test_tc06_stage_line_sums_all_developer_runs PASSED
tests/test_tracker_rollback_metrics.py::test_tc07_totals_converge_with_sum_agent_runs PASSED
tests/test_tracker_rollback_metrics.py::test_tc07_sum_of_stage_lines_equals_totals_on_done PASSED
tests/test_tracker_rollback_metrics.py::test_tc08_render_survives_null_timestamps_and_runs PASSED
tests/test_tracker_rollback_metrics.py::test_tc08_render_survives_bogus_stage PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[analysis] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[architecture] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[development] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[review] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[testing] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[deploy-staging] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[deploy] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[done] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_every_stage_has_meaningful_label[cancelled] PASSED
tests/test_tracker_status_line.py::test_orch091_tc01_created_stays_to_analyse PASSED
tests/test_tracker_status_line.py::test_orch091_tc02_deploy_staging_label PASSED
tests/test_tracker_status_line.py::test_orch091_tc03_unknown_stage_neutral_not_to_analyse PASSED
tests/test_tracker_status_line.py::test_orch091_tc03_cancelled_offline_label PASSED
... (полный набор регресса трекера/usage/webhooks/verdict-status зелёный)
======================= 1370 passed, 1 warning in 39.33s =======================
```
(1 warning — PydanticDeprecatedSince20 в `src/config.py:8`, преэкзистный, не связан с ORCH-091.)
## Итог
PASS
- Все 1370 тестов зелёные; новые тесты ORCH-091 (TC-01…TC-08) присутствуют и проходят.
- Каждый TC из тест-плана выполнен и сопоставлен с AC-1…AC-7.
- Smoke read-only OK; блоки `serial_gate` и `auto_labels` присутствуют в `GET /queue` (без регресса).
- Изменение indication-only / never-раise; регресс существующих меток карточки (AC-6) подтверждён.
**Вердикт: `result: PASS`** → задача переходит на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-091
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,36 @@
---
staging_status: SUCCESS
work_item: ORCH-091
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-09
model_used: claude-opus-4-8
timestamp: 2026-06-09T19:07:24Z
base_url: http://localhost:8501
---
# Staging Gate Log
> Машинный вердикт читается ТОЛЬКО из `staging_status:` во frontmatter. Реален для self-hosting
> (`orchestrator`); для прочих репо гейт — N/A (ORCH-35). `SUCCESS` → дальше; `FAILED` → откат.
Staging test suite completed against the live `orchestrator-staging` стенд (8501). Запуск
канонический — внутри контейнера `orchestrator-staging` через `docker exec`
(ORCH-048, ADR-001), mode=stub. **Exit code 0 → `staging_status: SUCCESS`.**
All REAL pipeline checks (Block A SMOKE, Block B ACCESS, C7/C8) passed. The two sandbox-infra
checks C9a/C9b failed and were **waived** under ORCH-061 tolerance (depend on SANDBOX bot accounts
being project members, not on the pipeline) — script still exited 0.
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
## Results
- **Block A (SMOKE)**: A1 `/health`→200 ok · A2 `/queue`→200 (counts/max_concurrency/resilience) · A3 `ORCH_STAGING=true`. PASS.
- **Block B (ACCESS)**: B4 Plane sandbox accessible (sandbox=YES) · B5 Gitea `orchestrator-sandbox` push=true · B6 Registry isolation (sandbox present, prod ET/ORCH absent). PASS.
- **Block C (E2E, stub)**: C7 create issue in Plane SANDBOX (HTTP 201) · C8 trigger pipeline `/webhook/plane` (accepted) PASS; C9a branch / C9b analyst-job — FAIL, **waived** (sandbox-infra). CLEANUP отработал (Plane issue удалён, ветки не было).
RESULT: 8/10 checks PASS. REAL failed: **none**. SANDBOX_INFRA failed (waived): C9a, C9b.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR
Work Item ID: ORCH-093
## Description
TBD

View File

@@ -0,0 +1,145 @@
---
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-093 — merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
Тип: **BUG** (надёжность self-deploy merge-фазы). Найдено по инциденту **ORCH-063 (09.06)**.
**Инцидент-первоисточник (ORCH-063, 09.06).** Прод-деплой self-hosting прошёл, staging OK, но при
мерже PR в `main` Gitea вернул `HTTP 405 {"message":"Please try again later"}` — транзиентная икота
(Gitea пересчитывал `mergeable` сразу после пуша). PR #98 был `open` + `mergeable=True`, конфликтов
**не было**. Однако merge-актор `merge_gate.merge_pr()`**one-shot**: на любой не-200/201 он сразу
вернул `(False, "merge failed: HTTP 405")`. Сработала корректная защита ORCH-071/081 «deploy
succeeded but not merged» → задача удержана на `deploy` (НЕ `done`), алерт, потребовался **ручной
домерж** (повтор `merge_pr` вручную → смержилось с первого раза). Защита отработала верно, но
**транзиент не должен был требовать человека**.
**Два дефекта, оба верифицированы по коду прода `src/merge_gate.py`:**
- **ДЕФЕКТ 1 — `merge_pr` не ретраит транзиентные HTTP-ошибки.** `merge_gate.merge_pr()`
(`src/merge_gate.py` ~700) делает **один** `POST /pulls/{index}/merge`; на любой не-200/201
(включая `405 "try again later"`, `5xx`, `409/422` «ещё считается mergeable») сразу
`return False, "merge failed: HTTP {code}"` — без ретрая. Сравни: у Claude-агентов есть
transient-breaker (`429/overload` ретраится), у merge-актора такого механизма нет → инфра-икота
Gitea = ложный HOLD.
- **ДЕФЕКТ 2 — `ensure_open_pr` плодит мусорные PR на уже влитой ветке.** При повторном прогоне
финализатора **после** ручного мержа: PR #98 уже `merged+closed``ensure_open_pr`
(`src/merge_gate.py` ~605) не находит открытого code-PR → **создаёт новый пустой PR #99** (ветка
уже в `main`, diff пустой). Пришлось закрывать вручную.
**Боль:** ложные HOLD при инфра-икоте Gitea требуют ручного вмешательства в автономный конвейер
(эпик ORCH-088 — пакетный автономный прогон) и оставляют мусорные пустые PR.
## 2. Объём (scope)
### В объёме
- `merge_pr` ретраит **транзиентные** ошибки мержа (405/«try again», 408, `5xx`, таймаут/сетевые, а
также `409/422` когда PR **всё ещё mergeable**) с ограниченным числом попыток и backoff — **перед**
тем как вернуть `False`.
- Различение «mergeable, но Gitea временно отказал» (ретраить) vs «реальный конфликт / не-mergeable»
(НЕ ретраить, честный быстрый HOLD).
- `ensure_open_pr` / merge-verify **не создаёт** новый PR, если ветка уже полностью в `main` (нет
коммитов `origin/main..branch`) — возвращает исход «already-in-main»; финализатор сразу доводит до
`done` без мусорного PR.
- Конфигурируемость (число ретраев, backoff, kill-switch на ретрай-поведение); разумные дефолты.
- Обновление `.env.example`, `CHANGELOG.md`, merge-gate-раздела документации.
### Вне объёма
- ❌ Снятие/ослабление защиты ORCH-071/081 «deploy succeeded but not merged» — она корректна; задача
лишь снижает **ложные** срабатывания на транзиентах.
- ❌ Ретрай **реального** конфликта / не-mergeable — это законный HOLD, нужен человек.
- ❌ Любые прямые `push`/`force-push` в `main` (инвариант INV-4 ORCH-071/073 — мерж только через
Gitea PR-merge API).
- ❌ Изменение `STAGE_TRANSITIONS`, состава `QG_CHECKS`, схемы БД.
- ❌ Изменение SHA-in-main-доказательства мержа (`verify_merged_to_main`) как источника истины.
## 3. Заинтересованные стороны
- **Заказчик / оператор автономного конвейера (Owner, Стрим)** — меньше ручных домержей, чище список
PR в Gitea.
- **Self-hosting репо `orchestrator`** — основной потребитель merge-verify under-gate (ORCH-071);
изменение в первую очередь касается self-deploy merge-фазы.
- **Все проекты на общем инстансе** — косвенно: меньше зависших на `deploy` задач, держащих
merge-lease и клинящих serial-gate репо (ORCH-088).
- **Reviewer / tester** — принимают результат по AC и зелёному `pytest`.
## 4. Бизнес-требования (BR)
- **BR-1** — При транзиентной ошибке мержа (`405`/«Please try again later», `408`, `5xx`,
таймаут/сетевая ошибка) `merge_pr` повторяет `POST …/merge` до `N` раз с backoff, прежде чем
вернуть `(False, …)`; успешный повтор внутри бюджета → `(True, …)`, мерж выполнен.
- **BR-2** — `merge_pr` различает «PR mergeable, Gitea временно отказал» (ретраить) и «реальный
конфликт / PR не mergeable» (НЕ ретраить). Различение опирается на код ответа **и** поле
`mergeable` PR (`GET /pulls/{n}`). Неоднозначный `409/422` классифицируется по `mergeable`.
- **BR-3** — Терминальные ошибки (`404` нет PR / реальный конфликт / `403`) НЕ ретраятся — `merge_pr`
возвращает `(False, …)` быстро; честный HOLD (защита ORCH-071/081) сохраняется.
- **BR-4** — При исчерпании ретраев `merge_pr` возвращает `(False, …)` с понятным reason; защита
«deploy succeeded but not merged» срабатывает как прежде (HOLD + алерт).
- **BR-5** — Если ветка уже полностью в `main` (нет коммитов `origin/main..branch`), `ensure_open_pr`
НЕ создаёт PR — возвращает исход «already-in-main»; merge-verify доводит задачу до `done` без
мусорного пустого PR.
- **BR-6** — Поведение ретрая конфигурируемо: число попыток, backoff и kill-switch; дефолты разумны
(≈3 попытки, backoff 25 с) и задокументированы в `.env.example`.
- **BR-7** — При выключенном ретрай-kill-switch поведение `merge_pr` идентично текущему (one-shot) —
нулевая регрессия.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (never-raise)** — Контракт never-raise `merge_pr` / `ensure_open_pr` сохранён: любая
HTTP/parse/сетевая ошибка → `(False, …)` / `("failed"|"already-in-main", …)`, исключение никогда не
пробрасывается в `_handle_merge_verify` / `advance_stage`.
- **NFR-2 (self-hosting safety / INV-4)** — Никаких прямых `push`/`force-push` в `main`; мерж только
через Gitea PR-merge API. Прод-контейнер `orchestrator` не перезапускается этой задачей.
- **NFR-3 (обратимость / kill-switch)** — Ретрай-поведение полностью отключаемо одним флагом →
откат к нынешнему one-shot без изменения кода.
- **NFR-4 (ограниченность)** — Суммарное время ретраев ограничено (`N` × backoff_max) и не может
«подвесить» monitor-поток, исполняющий merge-verify; backoff с верхним потолком.
- **NFR-5 (идемпотентность)** — Повторный прогон финализатора на уже влитой ветке безопасен и
бесследен (нет дублей PR, нет дублей мержа — переиспользуется `pr_already_merged`).
- **NFR-6 (наблюдаемость)** — Каждый ретрай и его причина логируются (по образцу `check_ci_green`:
`attempt i/N`); исход (успех/исчерпание/терминал) различим в логе.
## 6. Допущения и ограничения
- Gitea-код `405 {"message":"Please try again later"}`**транзиент** (Gitea пересчитывает
`mergeable` сразу после пуша); `5xx`/таймаут/сетевая — транзиент.
- `409` (conflict) и `422` (unprocessable) **двойственны**: либо реальный конфликт, либо «ещё не
пересчитан mergeable». Источник различения — поле `mergeable` из `GET /pulls/{n}` (а не только
код): `mergeable==True` → транзиент (ретраить), `mergeable==False` → реальный конфликт (НЕ
ретраить).
- `404` (нет PR) обрабатывается раньше шагом «no open PR» и/или трактуется как терминал.
- Образец паттерна ретрая уже есть в репо: `check_ci_green` (`src/qg/checks.py`, attempts + interval
+ backoff) и transient-breaker агентов (`backoff_base_seconds`/`backoff_max_seconds`/
`transient_max_attempts` в `config.py`).
- Merge-verify under-gate (ORCH-071) реален только для self-hosting (`merge_verify_applies`); на
прочих репо мерж делает LLM-deployer — там изменение `merge_pr` не задействуется.
- Изменение **точечное** в `src/merge_gate.py` + флаги в `src/config.py`; `STAGE_TRANSITIONS`,
`QG_CHECKS`, схема БД не трогаются.
## 7. Критерии успеха
`merge_pr` переживает транзиентную икоту Gitea (405/5xx/таймаут/«not mergeable yet») за счёт
ограниченного ретрая с backoff и больше не даёт ложного HOLD; реальный конфликт по-прежнему даёт
быстрый честный HOLD; `ensure_open_pr` не создаёт мусорных PR на уже влитой ветке; поведение
конфигурируемо и отключаемо; never-raise сохранён; `pytest tests/ -q` зелёный; доки и `.env.example`
обновлены. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- Слишком агрессивный ретрай реального конфликта → задержка честного HOLD (митигируется BR-2/BR-3:
классификация по `mergeable`).
- Ошибочная классификация транзиента как терминала (или наоборот) при неполном ответе Gitea
(`mergeable=None`) — нужна осторожная дефолт-политика.
- Гонка `ensure_open_pr` already-in-main vs параллельный мерж.
Детали и оценка — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,142 @@
---
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-093 — merge-актор ретраит транзиентные ошибки Gitea + гард «ветка уже в main»
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода
> (`src/merge_gate.py`, `src/config.py`, `src/stage_engine.py`). Архитектурное обоснование (точный
> алгоритм классификации, формат хелпера, выбор дефолтов) — задача архитектора (`06-adr`).
## 1. Сводка изменения
Две точечные доработки `src/merge_gate.py`:
1. **`merge_pr` (~700)** — обернуть `POST /pulls/{index}/merge` в **retry-loop** на транзиентных
кодах (`405`/«try again», `408`, `5xx`, таймаут/сетевые, плюс `409/422` при `mergeable==True`) с
ограниченным числом попыток и backoff; **терминальные** исходы (`404` нет PR, реальный конфликт /
`mergeable==False`, `403`) → быстрый `(False, …)` без ретрая. По образцу `check_ci_green`
(attempts + interval) и transient-breaker агентов.
2. **`ensure_open_pr` (~605)** — добавить гард «ветка уже полностью в `main`» (нет коммитов
`origin/main..branch`) → новый исход `"already-in-main"` **до** создания PR; в
`_handle_merge_verify` этот исход трактуется как «мерж уже состоялся» → SHA-in-main подтверждает →
`done` без мусорного PR.
Новые флаги ретрая в `src/config.py` (`ORCH_MERGE_RETRY_*`) + дескрипторы в `.env.example`. Контракт
never-raise и INV-4 (никогда не `push`/`force-push` `main`) — сохраняются. `STAGE_TRANSITIONS`,
`QG_CHECKS`, схема БД — **не трогаются**.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/merge_gate.py` | изменить — `merge_pr` (retry-loop + классификатор транзиент/терминал); `ensure_open_pr` (гард already-in-main); при необходимости leaf-хелперы `_is_transient_merge_error()` / `_branch_fully_in_main()` |
| `src/config.py` | изменить — добавить флаги ретрая мержа (`merge_retry_enabled`, `merge_retry_max_attempts`, `merge_retry_backoff_base_s`, `merge_retry_backoff_max_s`) по образцу `ci_poll_*` / `merge_pr_timeout_s` |
| `src/stage_engine.py` | изменить (точечно) — `_handle_merge_verify` (~1447): обработать новый исход `ensure_open_pr == "already-in-main"` как «мерж уже состоялся» (пропустить `merge_pr`, дать `verify_merged_to_main` подтвердить → `done`) |
| `.env.example` | изменить — новые дескрипторы `ORCH_MERGE_RETRY_*` |
| `tests/test_merge_gate.py` | изменить — мок httpx-последовательностей (405×2→200; конфликт; already-in-main; исчерпание; kill-switch off) |
| `CHANGELOG.md` | изменить — запись ORCH-093 |
| `docs/architecture/README.md` (merge-gate раздел) / `CLAUDE.md` | изменить — описать ретрай и гард already-in-main |
## 3. Функциональные требования
### FR-1 — retry-loop транзиентных ошибок мержа в `merge_pr` (BR-1, BR-4, BR-6, BR-7)
- Шаги `merge_pr` до `POST` (idempotency-guard `pr_already_merged`; `GET …/pulls?state=open` поиск
code-PR `head==branch AND base==main`; `index is None → (False, "no open PR")`) — **без изменений**.
- `POST /pulls/{index}/merge` выполняется в цикле до `merge_retry_max_attempts` попыток (дефолт `3`):
- `200/201``(True, "merged PR #<n>")` (немедленный выход).
- **транзиентный** исход (см. FR-2) И остались попытки → `sleep(backoff)` и повтор `POST`;
`backoff` экспоненциальный от `merge_retry_backoff_base_s` (дефолт `2`) с потолком
`merge_retry_backoff_max_s` (дефолт `5`).
- **терминальный** исход (см. FR-2) → немедленно `(False, "merge failed: HTTP <code>")` без
дальнейших попыток.
- исчерпание попыток на транзиенте → `(False, "merge failed after <N> attempts: HTTP <code>")`.
- **Kill-switch** `merge_retry_enabled=False` → ровно одна попытка `POST` (текущее one-shot
поведение, BR-7).
- Каждая попытка логируется (`attempt i/N`, код, transient/terminal) — образец `check_ci_green`.
### FR-2 — классификация транзиент vs терминал (BR-2, BR-3)
- **Транзиентные** (ретраить): `405` («Please try again later»), `408` (timeout), любой `5xx`,
`httpx`-таймаут / сетевая ошибка, **и** `409`/`422` когда PR **всё ещё mergeable**.
- **Терминальные** (НЕ ретраить, быстрый `False`): `403` (нет прав), `404` (PR исчез), и `409`/`422`
при **реальном конфликте** (`mergeable==False`).
- Различение неоднозначного `409`/`422`: дополнительный `GET /pulls/{index}` → поле `mergeable`:
- `mergeable==True` → транзиент (Gitea ещё не пересчитал) → ретрай.
- `mergeable==False` → реальный конфликт → терминал.
- `mergeable` отсутствует/`None` → консервативная дефолт-политика (рекомендация аналитика:
трактовать как транзиент с тем же ограниченным бюджетом ретраев, т.к. сетевая икота Gitea —
наблюдаемый кейс; финальное решение — архитектор в `06-adr`).
- Сетевые/таймаут-исключения `httpx` внутри попытки ловятся (never-raise) и классифицируются как
транзиент в рамках того же бюджета.
### FR-3 — гард «ветка уже полностью в main» в `ensure_open_pr` (BR-5)
- Перед шагом «создать PR» (после того как открытый code-PR не найден) `ensure_open_pr` проверяет,
что в ветке нет коммитов сверх `origin/main`: в per-branch worktree `git fetch origin main` +
`git rev-list --count origin/main..<branch>` (или `git merge-base --is-ancestor <branch> origin/main`).
- count `== 0` (ветка целиком в `main`) → `("already-in-main", "<reason>")`**PR не создаётся**.
- count `> 0` (есть невлитые коммиты) → текущий путь `POST …/pulls` (создать code-PR).
- git/OS ошибка проверки → **не** блокировать (never-raise); деградировать на текущее поведение
(попытаться создать PR) ИЛИ вернуть `failed` — точную fail-политику фиксирует архитектор. Гард
не должен превратить инфра-икоту git в ложный no-op мержа.
- Сигнатура возврата `ensure_open_pr` расширяется новым статусом `"already-in-main"` дополнительно к
`"existed"|"created"|"failed"` (обратносовместимо для существующих веток вызова).
### FR-4 — обработка `already-in-main` в `_handle_merge_verify` (BR-5)
- В `stage_engine._handle_merge_verify` (~1487): при `pr_status == "already-in-main"`
логировать, **пропустить** `merge_gate.merge_pr` (мержить нечего) и перейти сразу к
`verify_merged_to_main` (SHA-in-main подтвердит факт мержа → `done`). Это НЕ `failed`-ветка (не
HOLD): ветка уже в `main`, цель достигнута.
- SHA-in-main (`verify_merged_to_main`) остаётся **авторитетным** доказательством мержа; гард только
избегает мусорного PR и лишнего `merge_pr`.
### FR-5 — конфигурация и обратная совместимость (BR-6, BR-7)
- Новые поля `settings` (см. §2) с дефолтами; читаются из env (`ORCH_MERGE_RETRY_*`).
- При `merge_retry_enabled=False` — поведение `merge_pr` байт-в-байт как сейчас (one-shot).
- Гард already-in-main также под флагом ИЛИ всегда-вкл (рекомендация: всегда-вкл, т.к. он лишь
предотвращает создание заведомо пустого PR; решение — архитектор).
## 4. Изменения API
Нет (внешних HTTP-эндпоинтов оркестратора не добавляется/не меняется). Меняется только клиентское
обращение к Gitea API внутри `merge_gate` (дополнительный `GET /pulls/{index}` для чтения
`mergeable` при неоднозначном `409/422`; ретрай `POST …/merge`). Read-only блок merge-verify в
`GET /queue` (`merge_verify_status()`) опционально может получить счётчик ретраев (необязательно).
## 5. Изменения схемы БД
Нет. (Merge-lease — файловый, не БД; счётчики `_MERGE_VERIFY_COUNTERS` — in-process. Новые поля —
только в `config.Settings`, не в схеме.)
## 6. Требования к новым/изменённым QG checks
Нет. `STAGE_TRANSITIONS`, состав `QG_CHECKS`, exit-гейты рёбер и под-гейты ребра
`deploy-staging → deploy`**не трогаются**. Изменение целиком внутри детерминированного
merge-актора `merge_pr`/`ensure_open_pr` (под-гейт-врезка `_handle_merge_verify` ребра
`deploy → done`), который НЕ зарегистрирован в `QG_CHECKS`.
## 7. Совместимость / регресс
- **Kill-switch `merge_retry_enabled=False`** → one-shot `merge_pr` (текущее поведение) — нулевая
регрессия.
- **Защита ORCH-071/081** «deploy succeeded but not merged» сохраняется 1:1: после исчерпания
ретраев / на терминальном конфликте `merge_pr` возвращает `False`, и при неподтверждённом
SHA-in-main срабатывает прежний HOLD + алерт.
- **INV-4 / self-hosting safety**: никаких `push`/`force-push` в `main`; мерж только через Gitea
PR-merge API; прод-контейнер не перезапускается.
- **never-raise**: `merge_pr` / `ensure_open_pr` ловят все исключения и возвращают безопасный
кортеж — контракт сохранён (тесты на never-raise остаются зелёными).
- **Идемпотентность**: `pr_already_merged` (idempotency-guard) и гард already-in-main делают
повторный прогон финализатора бесследным (нет дублей PR/мержей).
- **Область раската**: реально задействуется на merge-verify under-gate (self-hosting,
`merge_verify_applies`); на прочих репо merge делает LLM-deployer — изменение нейтрально.
- **Артефакты pipeline**: создаётся/обновляется только аналитический пакет (`01``04`); в
development-стадии обновятся `CHANGELOG.md`, `.env.example`, merge-gate-раздел доки. ADR
(`06-adr/`) — пишет архитектор.
- Полный регресс `pytest tests/ -q` должен оставаться зелёным.

View File

@@ -0,0 +1,114 @@
---
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-093 — ретрай транзиентных merge-ошибок Gitea + гард already-in-main
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что
считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам.
---
## AC-1 — ретрай транзиента 405/5xx/таймаут → успешный мерж
**Условие:** `merge_pr` при транзиентной ошибке мержа повторяет `POST …/merge` с backoff и
доводит мерж до успеха в пределах бюджета.
- **PASS:** мок httpx даёт на `POST …/merge` `405` дважды, затем `200``merge_pr` возвращает
`(True, …)`, выполнено ровно 3 `POST`, ложного `False` нет. Аналогично для `5xx` и
таймаута/сетевой ошибки в первых попытках.
- **FAIL:** `merge_pr` возвращает `False` на первом `405`/`5xx`/таймауте (one-shot), не делая
повторных `POST`.
---
## AC-2 — реальный конфликт / не-mergeable НЕ ретраится (быстрый честный HOLD)
**Условие:** `merge_pr` при реальном конфликте (`409`/`422` с `mergeable==False`) или `403` не
зацикливается, а возвращает `(False, …)` быстро.
- **PASS:** мок httpx даёт `409` на `POST …/merge` и `GET /pulls/{n}` с `mergeable=False`
`merge_pr` возвращает `(False, …)` без дополнительных `POST` (не более одной попытки мержа);
reason различим как терминальный. `403` → немедленный `(False, …)`.
- **FAIL:** `merge_pr` ретраит реальный конфликт до исчерпания бюджета (вечный/долгий цикл),
задерживая честный HOLD.
---
## AC-3 — исчерпание ретраев → (False, …) + защита ORCH-071/081 как прежде
**Условие:** если транзиент не проходит за `N` попыток, `merge_pr` возвращает `(False, …)` с
понятным reason; защита «deploy succeeded but not merged» срабатывает как раньше.
- **PASS:** мок даёт `405` на всех `N` попытках → `merge_pr` возвращает
`(False, "merge failed after <N> attempts: HTTP 405")` (или эквивалент); в `_handle_merge_verify`
неподтверждённый SHA-in-main → HOLD + алерт (поведение ORCH-071/081 неизменно). Тест на
не-merged HOLD остаётся зелёным.
- **FAIL:** при исчерпании ретраев reason неинформативен; или защита HOLD не срабатывает / задача
ошибочно уходит в `done`.
---
## AC-4 — гард «ветка уже в main» → нет мусорного PR, задача доходит до done
**Условие:** если ветка уже полностью в `main` (нет коммитов `origin/main..branch`),
`ensure_open_pr` не создаёт PR и возвращает `already-in-main`; финализатор доводит до `done`.
- **PASS:** мок: открытого code-PR нет, `git rev-list --count origin/main..branch == 0`
`ensure_open_pr` возвращает `("already-in-main", …)` и **не делает** `POST …/pulls`; в
`_handle_merge_verify` этот статус пропускает `merge_pr` и `verify_merged_to_main` (SHA-in-main)
подтверждает мерж → задача доходит до `done` без создания пустого PR.
- **FAIL:** `ensure_open_pr` создаёт новый пустой PR на уже влитой ветке, либо статус
`already-in-main` ошибочно трактуется как `failed` (ложный HOLD).
---
## AC-5 — kill-switch / конфиг ретраев; дефолты задокументированы
**Условие:** ретрай-поведение конфигурируемо (число попыток, backoff, kill-switch); при выключении —
one-shot как сейчас; дефолты в `.env.example`.
- **PASS:** в `src/config.py` есть поля `merge_retry_enabled` / `merge_retry_max_attempts` /
`merge_retry_backoff_base_s` / `merge_retry_backoff_max_s` с разумными дефолтами (≈3 / 2 / 5);
`.env.example` содержит дескрипторы `ORCH_MERGE_RETRY_*`; при `merge_retry_enabled=False` тест
подтверждает ровно одну попытку `POST` (one-shot).
- **FAIL:** ретрай захардкожен (нет флагов/kill-switch), или `.env.example` не обновлён, или при
выключенном флаге поведение отличается от текущего one-shot.
---
## AC-6 — never-raise сохранён; регресс зелёный; доки обновлены
**Условие:** контракт never-raise `merge_pr`/`ensure_open_pr` цел; полный регресс зелёный;
документация и `CHANGELOG` обновлены.
- **PASS:** при любой HTTP/parse/сетевой ошибке (в т.ч. внутри ретрай-цикла и git-проверки гарда)
функции возвращают безопасный кортеж, исключение не пробрасывается; `pytest tests/ -q` зелёный;
merge-gate-раздел доки (`docs/architecture/README.md` / `CLAUDE.md`) и `CHANGELOG.md` описывают
ретрай и гард already-in-main.
- **FAIL:** исключение пробрасывается в `advance_stage`; падает любой тест в `tests/`; доки/CHANGELOG
не отражают изменение.
---
## AC-7 — инварианты self-hosting / INV-4 не нарушены
**Условие:** изменение не вводит прямых `push`/`force-push` в `main` и не трогает
`STAGE_TRANSITIONS`/`QG_CHECKS`/схему БД.
- **PASS:** мерж по-прежнему идёт только через Gitea PR-merge API; `git diff` не содержит правок
`STAGE_TRANSITIONS` / состава `QG_CHECKS` / схемы БД; никаких новых вызовов `git push … main`.
- **FAIL:** появился прямой push в `main`, либо изменены `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1, FR-2 |
| AC-2 | BR-2, BR-3 / FR-2 |
| AC-3 | BR-4 / FR-1 |
| AC-4 | BR-5 / FR-3, FR-4 |
| AC-5 | BR-6, BR-7 / FR-5 |
| AC-6 | NFR-1, NFR-6 / FR-1…FR-5 |
| AC-7 | NFR-2 / §6, §7 ТЗ |

View File

@@ -0,0 +1,116 @@
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
title: "Ретрай транзиентных merge-ошибок Gitea (405/5xx) + гард already-in-main"
framework: pytest
scope: >
Покрывает src/merge_gate.py::merge_pr (retry-loop + классификация транзиент/терминал) и
ensure_open_pr (гард «ветка уже в main»), новые флаги src/config.py (ORCH_MERGE_RETRY_*) и
обработку already-in-main в stage_engine._handle_merge_verify. Вне покрытия: реальная сеть Gitea,
STAGE_TRANSITIONS/QG_CHECKS, схема БД.
notes: >
httpx мокается monkeypatch'ем (по образцу tests/test_merge_gate.py / test_orch073_merge_pr.py):
последовательности ответов на POST /pulls/{n}/merge и GET /pulls/{n}. time.sleep патчится в no-op,
чтобы backoff не замедлял тесты. git-операции гарда (rev-list/merge-base) мокаются через
monkeypatch subprocess.run. Полный регресс tests/ должен оставаться зелёным; считается регрессом
любое падение существующих test_merge_gate*/test_merge_verify*/test_orch073*.
tests:
- id: TC-01
type: unit
description: "merge_pr: POST даёт 405,405,200 -> возвращает (True, merged PR #n); ровно 3 POST; ложного False нет (AC-1)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-02
type: unit
description: "merge_pr: POST даёт 503 (5xx), затем 200 -> ретрай -> (True, ...) (AC-1)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-03
type: unit
description: "merge_pr: POST бросает httpx Timeout/сетевую ошибку в 1-й попытке, затем 200 -> ретрай -> (True, ...); never-raise (AC-1, AC-6)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-04
type: unit
description: "merge_pr: реальный конфликт 409 + GET /pulls/{n} mergeable=False -> (False, ...) без доп. POST (терминал, быстрый HOLD) (AC-2)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-05
type: unit
description: "merge_pr: неоднозначный 409 + GET /pulls/{n} mergeable=True -> классифицирован как транзиент -> ретрай -> 200 -> (True, ...) (AC-2)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-06
type: unit
description: "merge_pr: 403 (нет прав) -> немедленно (False, ...) без ретрая (терминал) (AC-2)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-07
type: unit
description: "merge_pr: 405 на всех N попытках -> (False, 'merge failed after N attempts: HTTP 405') с понятным reason (AC-3)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-08
type: unit
description: "merge_pr: kill-switch merge_retry_enabled=False -> ровно один POST (one-shot, как сейчас) при 405 -> (False, ...) (AC-5, AC-3)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-09
type: unit
description: "ensure_open_pr: открытого code-PR нет, rev-list --count origin/main..branch == 0 -> ('already-in-main', ...); POST /pulls НЕ вызывается (AC-4)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-10
type: unit
description: "ensure_open_pr: открытого PR нет, есть невлитые коммиты (count>0) -> создаёт PR ('created', ...) (регресс прежнего поведения) (AC-4)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-11
type: unit
description: "ensure_open_pr: git-ошибка проверки гарда -> never-raise, безопасный кортеж, без падения (AC-6)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-12
type: unit
description: "merge_pr/ensure_open_pr: любая непойманная httpx/parse ошибка -> (False/failed, ...) кортеж, исключение не пробрасывается (never-raise) (AC-6)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-13
type: unit
description: "config: дефолты merge_retry_enabled/merge_retry_max_attempts/backoff_base/backoff_max присутствуют и читаются из ORCH_MERGE_RETRY_* env (AC-5)"
module: tests/test_config.py
expected: PASS
- id: TC-14
type: integration
description: "_handle_merge_verify: ensure_open_pr -> 'already-in-main' пропускает merge_pr, verify_merged_to_main (SHA-in-main) подтверждает -> задача доходит до done без мусорного PR (AC-4)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-15
type: integration
description: "_handle_merge_verify: merge_pr исчерпал ретраи (False) и SHA-in-main не подтверждён -> HOLD + alert (ORCH-071/081 как прежде), задача удержана на deploy, не done (AC-3)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-16
type: integration
description: "_handle_merge_verify happy-path: транзиент 405x2->200 в merge_pr -> SHA-in-main подтверждён -> done без ложного HOLD (end-to-end под-гейта deploy->done) (AC-1)"
module: tests/test_merge_verify.py
expected: PASS

View File

@@ -0,0 +1,222 @@
---
work_item: ORCH-093
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-001: Ретрай транзиентных merge-ошибок Gitea + гард «ветка уже в `main`» (ORCH-093)
Work Item: **ORCH-093** — merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`**
(амендмент к [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md) /
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md) /
[adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md) — лехатая merge-verify под-гейта).
## Статус
Proposed
## Контекст
Инцидент **ORCH-063 (09.06)**: self-deploy прошёл, staging OK, PR #98 был `open` + `mergeable=True`,
конфликтов не было — но `POST /pulls/98/merge` вернул `HTTP 405 {"message":"Please try again later"}`
(Gitea пересчитывал `mergeable` сразу после пуша). Сверено по коду прода `src/merge_gate.py`:
- **`merge_pr` (`src/merge_gate.py:700`) — one-shot.** Тело цикла отсутствует: единственный
`POST /pulls/{index}/merge` (стр. 747-752); любой не-`200/201` → немедленно
`return False, "merge failed: HTTP {code}"` (стр. 761). Транзиентная икота Gitea = мгновенный
`False`. Сработала корректная защита ORCH-071/073 «deploy succeeded but not merged»
(`_handle_merge_verify`, `src/stage_engine.py:1527`) → задача удержана на `deploy`, алерт,
**потребовался ручной домерж** (повтор `merge_pr` вручную → влилось с первого раза).
- **`ensure_open_pr` (`src/merge_gate.py:605`) — плодит мусорный PR.** При повторном прогоне
финализатора **после** ручного мержа: код-PR уже `merged+closed``_find_open_code_pr()`
(стр. 639) → `None` → шаг 2 `POST …/pulls` (стр. 663) создаёт **новый пустой PR** на ветке,
которая уже целиком в `main` (diff пустой). Пришлось закрывать вручную.
Контраст: у Claude-агентов есть transient-breaker (`429/overload` ретраится,
`config.transient_max_attempts`/`backoff_*`), у CI-гейта — `check_ci_green`
(`src/qg/checks.py:82`, `ci_poll_max_attempts` × `ci_poll_interval_s` с логом `attempt i/N`).
У детерминированного merge-актора аналога нет. Защита ORCH-071/073 отработала верно, но
**транзиент не должен был требовать человека** — это блокер автономного прогона (эпик ORCH-088) и
оставляет мусор в списке PR Gitea.
«Как есть» не годится: инфра-икота Gitea = ложный HOLD + ручное вмешательство в автономный конвейер.
## Решение
### Сводка
Две точечные доработки `src/merge_gate.py`, обе **аддитивны**, never-raise, под существующими
kill-switch'ами; `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — не трогаются; INV-4 (мерж только
через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён.
1. **`merge_pr`** — обернуть **только** `POST …/merge` в ограниченный retry-loop на транзиентных
исходах; терминальные → быстрый честный `False` (защита ORCH-071/073 — как прежде).
2. **`ensure_open_pr`** — гард «ветка уже полностью в `main`» **до** создания PR → новый исход
`"already-in-main"`; `_handle_merge_verify` трактует его как «мержить нечего» и даёт
авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done` без мусорного PR.
### D1 — retry-loop вокруг `POST …/merge` в `merge_pr` (BR-1, BR-4, BR-6, BR-7 / FR-1)
Шаги `merge_pr` **до** POST — без изменений (идемпотентность `pr_already_merged`; `GET …/pulls?state=open`
поиск код-PR `head==branch AND base==main`; `index is None → (False, "no open PR")`). Ретраится
**исключительно** мутирующий `POST /pulls/{index}/merge`:
- Цикл `for attempt in range(1, N+1)`, `N = settings.merge_retry_max_attempts` (дефолт `3`).
- `200/201` → немедленный `(True, "merged PR #<n>")`.
- **транзиентный** исход (D2) И `attempt < N` → лог `attempt i/N` (образец `check_ci_green`) →
`time.sleep(backoff(attempt))` → повтор POST.
- **терминальный** исход (D2) → немедленно `(False, "merge failed: HTTP <code>")`, без ретрая.
- исчерпание на транзиенте → `(False, "merge failed after <N> attempts: HTTP <code>")`.
**Backoff** — экспоненциальный c потолком (идиома transient-breaker агентов, ограничен NFR-4):
`backoff(i) = min(merge_retry_backoff_base_s * 2**(i-1), merge_retry_backoff_max_s)`
(дефолты base `2`, max `5`). Суммарный сон ограничен `(N-1) × backoff_max ≤ 10 с`; плюс
`merge_pr_timeout_s` на POST → верхняя граница задержки детерминирована и **не подвешивает**
monitor-поток, исполняющий merge-verify (NFR-4).
**Kill-switch** `merge_retry_enabled=False` → ровно одна попытка POST = байт-в-байт текущее one-shot
поведение (BR-7, нулевая регрессия). Реализуется как `N_eff = N if merge_retry_enabled else 1` без
ветвления тела цикла.
Привязка: AC-1 (405×2→200 = 3 POST, `True`), AC-3 (405×N → `False` + понятный reason), AC-5
(kill-switch → 1 POST).
### D2 — классификация транзиент vs терминал (BR-2, BR-3 / FR-2)
Leaf-хелпер `_classify_merge_response(repo, branch, index, status_code) -> "transient" | "terminal"`
(never-raise). Дерево решений:
| Исход POST | Класс | Действие |
|------------|-------|----------|
| `405` («try again later»), `408`, любой `5xx` | **transient** | ретрай |
| `httpx`-таймаут / сетевое исключение | **transient** | ретрай (ловится внутри попытки, never-raise) |
| `403` (нет прав), `404` (PR исчез) | **terminal** | быстрый `False` |
| `409` / `422` | **ambiguous** → доп. `GET /pulls/{index}` → поле `mergeable` | см. ниже |
Разрешение неоднозначного `409/422` по `GET /pulls/{index}``mergeable`:
- `mergeable == True`**transient** (Gitea ещё не пересчитал — корневой кейс ORCH-063) → ретрай.
- `mergeable == False`**terminal** (реальный конфликт) → быстрый честный HOLD.
- `mergeable` отсутствует / `None` / сам `GET` упал → **transient** в рамках того же ограниченного
бюджета (см. дефолт-политику ниже).
**Дефолт-политика для `mergeable == None`/недоступного — транзиент** (принято от рекомендации
аналитика, FR-2). Обоснование: (а) цель задачи — не давать ложного HOLD на икоте Gitea, а икота —
именно наблюдаемый кейс с неполным/запаздывающим `mergeable`; (б) цена ошибки ограничена — даже
если за `None` скрывается реальный конфликт, бюджет ретраев конечен (`≤10 с`), после чего
`merge_pr` всё равно вернёт `False` → срабатывает **та же** защита ORCH-071/073 (HOLD + алерт);
(в) обратный выбор (терминал по `None`) воспроизводит ровно тот ложный HOLD, что чинит задача.
Таким образом дефолт fail-OPEN-в-ретрай безопасен: автономность выигрывает, корректность
backstop'а сохранена.
Привязка: AC-1 (транзиент → ретрай), AC-2 (`409`+`mergeable=False`/`403` → терминал, ≤1 POST).
### D3 — гард «ветка уже полностью в `main`» в `ensure_open_pr` (BR-5 / FR-3)
Новый leaf-хелпер `_branch_fully_in_main(repo, branch) -> bool | None` (never-raise), вызывается в
`ensure_open_pr` **после** того как `_find_open_code_pr()` вернул `None` и **до** `POST …/pulls`:
- В per-branch worktree (`ensure_worktree`, изоляция ORCH-2): `git fetch origin main`
`git merge-base --is-ancestor <branch-HEAD> origin/main` (идиома уже используется в
`branch_is_behind_main` / `verify_merged_to_main`; эквивалент `git rev-list --count origin/main..HEAD == 0`).
- `rc == 0` → ветка целиком в `main``True`.
- `rc == 1` → есть невлитые коммиты → `False`.
- git/OS-ошибка / ambiguous rc → `None`.
Маппинг в `ensure_open_pr`:
- `True` → новый исход `("already-in-main", "<reason>")`**PR не создаётся**.
- `False` → текущий путь шага 2 (`POST …/pulls` создать код-PR) — без изменений.
- `None` (**fail-OPEN**) → деградировать на текущее поведение (попытаться создать PR), **НЕ**
блокировать. Обоснование: единственная цель гарда — избежать заведомо пустого PR; вернуть
`"failed"` на git-икоте значило бы превратить инфра-икоту git в ложный no-op/HOLD мержа — ровно
анти-паттерн, против которого предостерегает BRD. SHA-in-main downstream остаётся авторитетным:
даже если на git-ошибке гард ошибётся и создаст пустой PR, это лишь косметика, не ложный `done`.
Сигнатура `ensure_open_pr` расширяется исходом `"already-in-main"` дополнительно к
`"existed"|"created"|"failed"` (обратносовместимо для существующих веток вызова).
**Без отдельного флага:** гард — чистый fail-OPEN correctness-guard, уже целиком накрыт
существующим kill-switch'ем `merge_verify_autocreate_pr_enabled` (вся врезка `ensure_open_pr` в
`_handle_merge_verify` под ним — `src/stage_engine.py:1486`). Отдельный флаг был бы избыточной
конфиг-поверхностью (принято от рекомендации FR-5: «всегда-вкл»).
Привязка: AC-4 (count==0 → `already-in-main`, нет POST …/pulls).
### D4 — обработка `already-in-main` в `_handle_merge_verify` (BR-5 / FR-4)
В `stage_engine._handle_merge_verify` (`src/stage_engine.py:1486-1495`): при
`pr_status == "already-in-main"` — лог, **пропустить** `merge_gate.merge_pr` (мержить нечего) и
сразу к `verify_merged_to_main` (SHA-in-main подтвердит факт мержа → `done`). Это **НЕ** `failed`-ветка
(не HOLD): цель уже достигнута, ветка в `main`. Реализуется флагом `skip_merge`, обнуляющим вызов
`merge_pr` на строке 1498; ветка `verify_merged_to_main` (стр. 1503) и весь нижестоящий код —
без изменений. SHA-in-main остаётся **авторитетным** доказательством мержа (ADR-0014); гард только
избегает мусорного PR и лишнего `merge_pr`.
Деградация safety: если по какой-то причине SHA не в `main` при `already-in-main` (не должно случаться,
т.к. `sha = validated_revision = worktree HEAD`, а ветка целиком в `main`), срабатывает прежний
HOLD (стр. 1527) — fail-closed, безопасно.
Привязка: AC-4 (`already-in-main` → пропуск `merge_pr`, SHA-in-main → `done`).
### D5 — конфигурация (BR-6, BR-7 / FR-5)
Новые поля `src/config.Settings` (по образцу `ci_poll_*` / `merge_pr_timeout_s`), читаются из env:
| Поле | env | Дефолт |
|------|-----|--------|
| `merge_retry_enabled` | `ORCH_MERGE_RETRY_ENABLED` | `True` (kill-switch; `False` → one-shot) |
| `merge_retry_max_attempts` | `ORCH_MERGE_RETRY_MAX_ATTEMPTS` | `3` |
| `merge_retry_backoff_base_s` | `ORCH_MERGE_RETRY_BACKOFF_BASE_S` | `2` |
| `merge_retry_backoff_max_s` | `ORCH_MERGE_RETRY_BACKOFF_MAX_S` | `5` |
Дескрипторы добавляются в `.env.example`. Гард already-in-main — без отдельного флага (D3).
## Альтернативы
- **Ретрай всех steps `merge_pr` (включая `GET …/pulls?state=open`)** — отвергнуто: ретраить нужно
только мутирующий POST; список PR — дешёвый идемпотентный GET, его транзиент-ретрай усложняет
логику без выгоды (повторный POST сам перечитает при необходимости через `pr_already_merged`).
- **Терминал по `mergeable == None`** — отвергнуто: воспроизводит ложный HOLD, который чинит задача
(см. D2); бюджет ретраев конечен, backstop ORCH-071/073 сохранён.
- **Фиксированный interval-backoff (как `check_ci_green`)** — отвергнуто в пользу экспоненциального
с потолком: merge-икота короткая, экспонента с малым потолком (`5 с`) быстрее проходит первую
попытку и жёстко ограничена сверху (NFR-4).
- **`"failed"` на git-ошибке гарда already-in-main** — отвергнуто: превращает икоту git в ложный
no-op/HOLD мержа (анти-паттерн BRD); выбран fail-OPEN-в-create (D3).
- **Отдельный kill-switch для гарда already-in-main** — отвергнуто: уже накрыт
`merge_verify_autocreate_pr_enabled`; лишняя конфиг-поверхность.
- **Снять/ослабить защиту ORCH-071/081** — вне объёма и неверно: защита корректна, задача лишь
снижает **ложные** срабатывания.
## Последствия
- **+** Транзиентная икота Gitea (405/5xx/таймаут/«not mergeable yet») переживается автоматически →
нет ложного HOLD, нет ручного домержа в автономном прогоне (ORCH-088).
- **+** Нет мусорных пустых PR на уже влитой ветке; повторный прогон финализатора идемпотентен (NFR-5).
- **+** Реальный конфликт по-прежнему даёт быстрый честный HOLD (≤1 POST); защита ORCH-071/073 — 1:1.
- **+** Наблюдаемость: каждый ретрай логируется `attempt i/N` + класс (transient/terminal) (NFR-6).
- **** Доп. `GET /pulls/{index}` на неоднозначном `409/422` (один лишний дешёвый запрос только в
редком ambiguous-кейсе) — приемлемо.
- **** Дефолт-политика `mergeable==None → transient` может на реальном конфликте добавить ≤10 с
до HOLD. Митигейшн: бюджет жёстко ограничен; HOLD всё равно срабатывает.
- **** Расширение возврата `ensure_open_pr` новым исходом — все вызовы перечислены, BC сохранён.
- **Откат:** `ORCH_MERGE_RETRY_ENABLED=false` → one-shot `merge_pr` (нынешнее поведение);
`ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=false` отключает врезку `ensure_open_pr` целиком (вместе
с гардом). Полный откат кода — revert PR; флаги дают мгновенный runtime-откат без деплоя кода.
## Ссылки
- BRD: `docs/work-items/ORCH-093/01-brd.md`
- TRZ: `docs/work-items/ORCH-093/02-trz.md`
- Acceptance: `docs/work-items/ORCH-093/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-093/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`
- Лехатая merge-verify: [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md),
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md),
[adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md)
- Сверено по коду: `src/merge_gate.py` (`merge_pr:700`, `ensure_open_pr:605`,
`branch_is_behind_main:53`, `verify_merged_to_main:767`), `src/stage_engine.py`
(`_handle_merge_verify:1447`), `src/qg/checks.py` (`check_ci_green:82`), `src/config.py`
(`ci_poll_*:140`, `merge_pr_timeout_s:549`, `transient_max_attempts:77`)

View File

@@ -0,0 +1,36 @@
---
work_item: ORCH-093
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-093 — ретрай транзиентных merge-ошибок Gitea + гард already-in-main
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | Ошибочная классификация реального конфликта как транзиента (`mergeable==None`/неполный ответ) → лишние ретраи перед HOLD | Сред. | Низ. | D2: бюджет ретраев жёстко ограничен (`(N-1)×backoff_max ≤ 10 с`); после исчерпания — тот же HOLD ORCH-071/073. Цена ≤10 с задержки, не ложный `done`. |
| TR-2 | Слишком агрессивный/долгий ретрай подвешивает monitor-поток, исполняющий merge-verify | Низ. | Сред. | D1/NFR-4: экспон. backoff с потолком `merge_retry_backoff_max_s`; суммарный сон детерминирован; `merge_pr_timeout_s` ограничивает каждый POST. |
| TR-3 | Гонка гарда already-in-main vs параллельный мерж (ветка влита между `_find_open_code_pr` и `_branch_fully_in_main`) | Низ. | Низ. | SHA-в-main (`verify_merged_to_main`, ADR-0014) остаётся авторитетным; гард лишь избегает пустого PR. Ложный `done` невозможен — решает SHA, не гард. |
| TR-4 | git-икота гарда (`fetch`/`merge-base` падает) → ложный `already-in-main` → пропуск реального мержа | Низ. | Выс. | D3: fail-OPEN — `None` деградирует на create-PR, НЕ на `already-in-main`; ложный пропуск мержа структурно невозможен (для `already-in-main` нужен rc==0, не ошибка). |
| TR-5 | Регрессия one-shot поведения при `merge_retry_enabled=False` | Низ. | Сред. | BR-7: `N_eff = 1` без ветвления тела цикла; тест AC-5 подтверждает ровно один POST. |
| TR-6 | Расширение возврата `ensure_open_pr` (`already-in-main`) ломает необработанную ветку вызова | Низ. | Сред. | Все вызовы перечислены (`_handle_merge_verify`, `launcher._ensure_pr`); BC: новый исход обрабатывается явно, прочие пути 1:1. Покрытие — тест AC-4. |
| TR-7 | Лишний `GET /pulls/{index}` на ambiguous `409/422` сам транзиентно падает → неверный класс | Низ. | Низ. | never-raise: сбой `GET` → дефолт transient в рамках бюджета (D2); никогда не исключение в `advance_stage`. |
## Сводный вывод
Доминирующий класс — **корректность классификации транзиент/терминал** (TR-1, TR-4): обе ветки
спроектированы fail-safe в сторону, противоположную багу (ретрай-с-бюджетом и fail-OPEN-в-create),
с авторитетным backstop'ом SHA-в-main + защитой ORCH-071/073, которые не трогаются. Остаточный риск
для прод-конвейера (self-hosting) **низкий**: изменение точечное, аддитивное, полностью отключаемо
двумя существующими/новыми kill-switch'ами без деплоя кода; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД
не затронуты. Эскалация `arch:major-change` **не требуется**; возврат в анализ **не требуется**
ТЗ реализуемо без нарушения принципов архитектуры.

View File

@@ -0,0 +1,89 @@
---
verdict: APPROVED
work_item: ORCH-093
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-09
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-093
version: 1
---
# Review ORCH-093
## Summary
Две точечные доработки детерминированного merge-актора (`src/merge_gate.py`), чинящие инцидент
**ORCH-063** (ложный HOLD на транзиентном `HTTP 405` от Gitea + мусорный пустой PR на уже влитой
ветке): (1) retry-loop вокруг мутирующего `POST …/merge` с классификатором транзиент/терминал;
(2) гард `already-in-main` в `ensure_open_pr` + врезка в `_handle_merge_verify`.
Реализация **полностью соответствует** ТЗ (FR-1…FR-5), критериям приёмки (AC-1…AC-7) и ADR-001
(D1…D5). Контракты сохранены: never-raise, INV-4 (мерж только через Gitea PR-merge API, никогда
`push`/`force-push` в `main`), `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — байт-в-байт не тронуты
(проверено `git diff`: затронуты только `src/merge_gate.py`, `src/config.py` и точечно
`src/stage_engine.py`). Защита ORCH-071/073/081 («deploy succeeded but not merged») сохранена 1:1:
терминал/исчерпание ретраев → `(False, …)` → прежний HOLD+alert.
**Тесты содержательные и зелёные:** `tests/test_merge_gate.py` (TC-01…TC-12), `tests/test_config.py`
(TC-13), `tests/test_merge_verify.py` (TC-14…TC-16), обновлён `tests/test_orch082_ensure_pr.py`.
Локальный прогон затронутых сьютов — **72 passed**. Каждый AC покрыт буквально (405×2→200=3 POST;
5xx→200; network→200; реальный конфликт/403 терминал без ретрая; ambiguous-409+mergeable=True ретрай;
исчерпание; kill-switch one-shot; already-in-main без POST; fail-OPEN на git-ошибке гарда;
never-raise).
**Трассировка (TRACEABILITY.md):** правки в блоках с маркерами ORCH-071/073/082 сверены с их
инвариантами — SHA-in-main остаётся единственным авторитетным доказательством мержа (ADR-0014),
idempotency-guard `pr_already_merged`, фильтр `base==main` для code-PR, never-raise — сохранены.
В append-only `MAIN_REGRESSION_MARKERS` корректно добавлена строка
`("ORCH-093", "_classify_merge_response", "src/merge_gate.py")` — без слома существующих маркеров.
Документация обновлена (CHANGELOG, `.env.example`, `CLAUDE.md`, локальный ADR-001 + сквозной
adr-0027, `docs/architecture/README.md`). Один P2 по гигиене документации (дубль секции в README) —
не блокирует приёмку.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- [ ] **Дубль секции ORCH-093 в `docs/architecture/README.md`.** Один и тот же заголовок
`#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD
на 405/5xx)` встречается **дважды** — строки **480516** и **518550**с почти идентичным,
перекрывающимся содержимым и совпадающим markdown-anchor'ом. Подтверждено `git diff` (на `origin/main`
— 0 вхождений, на ветке — 2), т.е. обе секции добавлены этим PR (вероятно случайная вставка/дубль
блока при правке golden-source). README — обзорная витрина архитектуры; дублирующий блок с
коллизией заголовков следует схлопнуть в одну секцию (оставить вариант 480516 или 518550, не оба).
Правило: `CLAUDE.md` §2 «документация = golden source», стандарт обзорных доков (ORCH-079).
### P3 — Nice to have
- [ ] **`tests/test_merge_gate.py::_PostSeq`** обращается к `self._items_last` до его первой
инициализации, если конструктору передать пустой список (атрибут ставится только после первого
`pop`). Сейчас не срабатывает (все вызовы передают непустую последовательность), но защититься
дефолтом `self._items_last = None` в `__init__` дешевле, чем потенциальный `AttributeError` при
будущем редактировании теста.
## Документация
Проверка обязательна (изменён `src/`). Статус — **обновлена** (golden source синхронизирован с кодом):
| Артефакт | Статус |
|----------|--------|
| `CHANGELOG.md` | ✅ запись ORCH-093 (`[Unreleased]`) с детализацией retry/guard/конфиг/тесты |
| `.env.example` | ✅ дескрипторы `ORCH_MERGE_RETRY_*` (4 поля) + пояснительный блок |
| `CLAUDE.md` | ✅ абзац ORCH-093 в секции «Очередь задач» |
| `docs/architecture/README.md` | ⚠️ обновлена, но **секция продублирована** (P2 — схлопнуть) |
| `docs/work-items/ORCH-093/06-adr/ADR-001-…md` | ✅ локальный ADR (proposed) |
| `docs/architecture/adr/adr-0027-…md` | ✅ сквозной ADR (amends 0013/0014/0016) |
API / `STAGE_TRANSITIONS` / QG / схема БД не менялись → доп. обновлений не требуется. Пункт
`README.md` «Известные ограничения» данным PR не закрывается (ORCH-079 не применим).
**Вывод:** P0/P1 нет; единственный P2 — косметический дубль секции README (не блокирует). Verdict —
`APPROVED`. Рекомендую попутно схлопнуть дубль перед мержем.

View File

@@ -0,0 +1,83 @@
---
result: PASS
work_item: ORCH-093
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-09
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-093
---
# Test Report — ORCH-093
merge-актор ретраит транзиентные ошибки Gitea (405/5xx/таймаут) + гард «ветка уже в main».
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-093-bug-merge-gitea-405-5xx-hold-p`
- Branch: `feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p`
- Дата: 2026-06-09
## Предусловия
- Review verdict: **APPROVED** (`12-review.md`, P0/P1 нет; единственный P2 — косметический дубль секции README, не блокирует).
## Smoke API (read-only, prod 8500)
| Эндпоинт | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | OK (active_tasks отдаётся; ORCH-093 task#78 в `testing`) |
| `GET /queue` | OK — блок `serial_gate` **присутствует** (ORCH-088), `auto_labels` **присутствует** (ORCH-089), `stop` присутствует (ORCH-090). Регресса смока нет. |
## Результаты (покрытие ТЗ — каждый TC из 04-test-plan.yaml)
| TC ID | Описание (AC) | Тест | Результат |
|-------|---------------|------|-----------|
| TC-01 | merge_pr: 405,405,200 → (True, …); ровно 3 POST; ложного False нет (AC-1) | `test_merge_gate.py::test_tc01_merge_retries_405_then_succeeds` | PASS |
| TC-02 | merge_pr: 503 (5xx)→200 → ретрай → (True, …) (AC-1) | `test_merge_gate.py::test_tc02_merge_retries_5xx_then_succeeds` | PASS |
| TC-03 | merge_pr: httpx Timeout/сетевая→200 → ретрай; never-raise (AC-1, AC-6) | `test_merge_gate.py::test_tc03_merge_retries_network_error_then_succeeds` | PASS |
| TC-04 | merge_pr: 409 + GET mergeable=False → (False, …) без доп. POST (терминал) (AC-2) | `test_merge_gate.py::test_tc04_real_conflict_terminal_no_retry` | PASS |
| TC-05 | merge_pr: ambiguous 409 + GET mergeable=True → транзиент → ретрай → 200 (AC-2) | `test_merge_gate.py::test_tc05_ambiguous_409_mergeable_true_retries` | PASS |
| TC-06 | merge_pr: 403 → немедленно (False, …) без ретрая (терминал) (AC-2) | `test_merge_gate.py::test_tc06_403_terminal_no_retry` | PASS |
| TC-07 | merge_pr: 405 на всех N → (False, 'merge failed after N attempts…') понятный reason (AC-3) | `test_merge_gate.py::test_tc07_exhausts_retries_clear_reason` | PASS |
| TC-08 | merge_pr: kill-switch off → ровно один POST (one-shot) при 405 (AC-5, AC-3) | `test_merge_gate.py::test_tc08_killswitch_off_one_shot` | PASS |
| TC-09 | ensure_open_pr: count==0 → ('already-in-main', …); POST /pulls НЕ вызван (AC-4) | `test_merge_gate.py::test_tc09_ensure_already_in_main_no_post` | PASS |
| TC-10 | ensure_open_pr: count>0 → создаёт PR (регресс прежнего поведения) (AC-4) | `test_merge_gate.py::test_tc10_ensure_creates_when_commits_beyond_main` | PASS |
| TC-11 | ensure_open_pr: git-ошибка гарда → never-raise, fail-open (AC-6) | `test_merge_gate.py::test_tc11_ensure_guard_git_error_fail_open`, `::test_tc11_branch_fully_in_main_never_raises` | PASS |
| TC-12 | merge_pr/ensure_open_pr: любая httpx/parse ошибка → безопасный кортеж, never-raise (AC-6) | `test_merge_gate.py::test_tc12_merge_pr_never_raises`, `::test_tc12_ensure_open_pr_never_raises` | PASS |
| TC-13 | config: дефолты merge_retry_* + чтение ORCH_MERGE_RETRY_* env (AC-5) | `test_config.py::test_merge_retry_settings_defaults`, `::test_merge_retry_settings_env_override` | PASS |
| TC-14 | _handle_merge_verify: 'already-in-main' пропускает merge_pr, SHA-in-main → done (AC-4) | `test_merge_verify.py::test_tc14_already_in_main_skips_merge_pr_then_done` | PASS |
| TC-15 | _handle_merge_verify: merge_pr исчерпал ретраи + SHA не подтверждён → HOLD+alert (ORCH-071/081) (AC-3) | `test_merge_verify.py::test_tc15_merge_failed_and_not_in_main_holds` | PASS |
| TC-16 | _handle_merge_verify happy-path: 405x2→200 → SHA-in-main → done без ложного HOLD (AC-1) | `test_merge_verify.py::test_tc16_transient_retry_success_then_done` | PASS |
**Сопоставление с `03-acceptance-criteria.md`:** AC-1 (TC-01/02/03/16), AC-2 (TC-04/05/06),
AC-3 (TC-07/15), AC-4 (TC-09/10/14), AC-5 (TC-08/13), AC-6 (TC-11/12 + зелёный регресс),
AC-7 (`STAGE_TRANSITIONS`/`QG_CHECKS`/сигнатуры неизменны — `test_config.py::test_tc19_*` зелёные).
Все 16 TC выполнены и сопоставлены.
## Вывод pytest
Полный регресс из worktree ветки задачи:
```
$ cd /repos/_wt/orchestrator/feature_ORCH-093-bug-merge-gitea-405-5xx-hold-p && pytest tests/ -v --tb=short
...
======================= 1389 passed, 1 warning in 44.62s =======================
```
Целевые сьюты ORCH-093 (`test_merge_gate.py`, `test_config.py`, `test_merge_verify.py`,
`test_orch082_ensure_pr.py`):
```
======================== 72 passed, 1 warning in 1.84s =========================
```
Единственный warning — `PydanticDeprecatedSince20` (class-based config, существующий, не связан с ORCH-093).
Падений и регрессов `test_merge_gate*/test_merge_verify*/test_orch08*/test_config*` нет.
## Итог
PASS — все 1389 тестов зелёные, целевые TC-01…TC-16 PASS и сопоставлены с AC-1…AC-7,
smoke read-only OK (`serial_gate`/`auto_labels` присутствуют в `/queue`). Задача переходит на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-093
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,38 @@
---
staging_status: SUCCESS
work_item: ORCH-093
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-09
model_used: claude-opus-4-8
timestamp: 2026-06-09T19:44:29Z
base_url: http://localhost:8501
---
# Staging Gate Log
> Машинный вердикт читается ТОЛЬКО из `staging_status:` во frontmatter. `SUCCESS` → дальше; `FAILED` → откат.
Staging test suite completed against the live staging instance. All REAL pipeline checks passed.
Run canonically **inside the `orchestrator-staging` container** (8501) via the Docker Engine API
exec endpoint (the `docker` CLI is absent in this agent environment, so the exec was driven over
`/var/run/docker.sock`; semantics identical to `docker exec` — the script ran with the
container's own `.env.staging` process-env, so B6 registry-isolation is authoritative).
Command (in-container):
`python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
**Exit code: 0 → SUCCESS.**
## Results — 8/10 checks PASS
- **Block A (SMOKE)**: PASS — A1 `/health` 200 ok · A2 `/queue` 200 with counts/max_concurrency/resilience · A3 `ORCH_STAGING=true`.
- **Block B (ACCESS)**: PASS — B4 Plane sandbox project accessible · B5 Gitea `orchestrator-sandbox` accessible, push=true · B6 Registry isolation (sandbox=YES, prod-ET=NO, prod-ORCH=NO).
- **Block C (E2E, mode=stub)**: C7 create issue in Plane SANDBOX PASS · C8 trigger pipeline via `/webhook/plane` PASS · C9a/C9b FAILED (sandbox-infra, **waived** per ORCH-061). Cleanup OK (Plane issue deleted, no branch to delete).
REAL failed: **none**.
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: терминальная (done) задача флаппит deploy-статусы в Plane (Awaiting↔Monitoring), не держит Done
Work Item ID: ORCH-094
## Description
TBD

View File

@@ -0,0 +1,155 @@
---
work_item: ORCH-094
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-094 — терминальная (done) задача флаппит deploy-статусы в Plane (Awaiting↔Monitoring), не держит Done
Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
**Тип:** BUG — рассинхрон БД↔Plane / «зомби»-цикл post-deploy-статуса (self-hosting).
**Симптом (верифицирован живьём 09.06 на ORCH-061):**
Задача ORCH-061 в БД оркестратора = `done` с 07.06 (task 47; фикс задеплоен в прод; конвейер её
не трогает — 0 активных job'ов). При этом карточка задачи в **Plane не держит Done**: непрерывно
флаппит `Monitoring after Deploy ⟷ Awaiting Deploy` парами (туда-обратно за ~2 сек), каждые
несколько минут. Накоплено 273 активности. Доходило до абсурда: 09.06 14:56 встала в `Done`
15:48 её выдернуло обратно `Done → Awaiting Deploy`. Воспроизводится детерминированно: ручной
sync 061→Done (PATCH 200, 16:47) → через ~60 сек снова `Done → Awaiting Deploy → Monitoring`
(16:48). Само **не затихает**.
**Установленные факты (по логам/БД прода + чтение кода ветки):**
- **Сам оркестратор не инициирует переходы из своих штатных стадийных обработчиков для done-задачи.**
В момент флаппа лог орка показывает только **входящие** webhook-и Plane
(`issue … updated to state … (Awaiting Deploy) → no pipeline action`, затем `(Monitoring) →
no pipeline action`). Обработчик `webhooks/plane.py::handle_issue_updated` для статусов
Awaiting/Monitoring логирует «no pipeline action» и **сам статус не переотправляет** (echo-loop
обработчика исключён).
- **Actor всех 273 переходов** = `daf4d3f4-55df-4016-9095-0cf9ddd8fd28` — бот-актор оркестратора
(тот же токен, под которым орк делает гигиену доски / sync). То есть PATCH-и шлёт **что-то под
токеном орка**, не привязанное к активной task/job в БД.
- В БД орка **нет активного post-deploy-monitor** для task 47 (pdm активен только у текущей
063/task 74). `orchestrator-staging` (8501) — не источник (task 061 в его БД отсутствует).
- В коде ветки **единственные три писателя** deploy-статусов — `src/stage_engine.py`:
`set_issue_monitoring` (строка 404, на переходе `deploy → done` для self-hosting),
`set_issue_awaiting_deploy` (строка 1218, Phase A), `set_issue_deploying` (строка 1316, Phase B).
Все три — **внутри стадийных обработчиков** (`advance_stage` / `_handle_self_deploy_phase_*`),
ни один не сидит в фоновом цикле, независимом от таблицы `jobs`.
- `notifications.py::_live_plane_branch_override` **только читает** живой Plane-статус (для рендера
карточки) — писателем не является.
- Реконсилятор: F-1 пропускает задачи со `stage in ('done','cancelled')` (terminal-skip ORCH-086);
F-2 опрашивает issue **только** в статусах `[to_analyse, approved, rejected]` — статусы
`Monitoring`/`Awaiting` он не перебирает. **Механизма «привести done-задачу, застрявшую на
deploy-статусе, обратно к Done» (идемпотентного схождения) — нет.**
**Боль:** карточка вводит наблюдателя в заблуждение («задача деплоится», хотя она в проде и done),
шумит активностью (273 события на одной задаче), **вечно жжёт API-вызовы Plane** флаппом и
маскирует реальное состояние доски. Конвейер технически не нарушен (задача в проде), поэтому
приоритет **MEDIUM**, но дефект бессрочный и самовоспроизводящийся.
**Родственные задачи:** ORCH-091 (врущие/застывшие статусы карточки), ORCH-068/086 (терминал-скип
как защита инвариантов). ORCH-094 распространяет идею терминал-скипа на deploy-статусы и закрывает
источник флаппа.
## 2. Объём (scope)
### В объёме
- **G1 — устранить источник** PATCH-ей deploy-статуса на задачу, у которой в БД `stage=done` и нет
активного job'а. Терминальная (done) задача в Plane должна стабильно держать `Done` и не получать
`Awaiting`/`Monitoring`.
- **G2 — идемпотентность sync/setter'ов:** если БД=`done`, любой sync/монитор/реконсилятор/прямой
вызов приводит Plane к `Done` (не к промежуточному deploy-статусу) — терминал-скип/схождение,
распространённые на статусы `Monitoring`/`Awaiting` (как ORCH-068/086 для других статусов).
- **G3 — детерминированный конец post-deploy-monitor:** монитор завершается чётко (HEALTHY / N тиков
→ Done) и не оставляет «зомби»-таймеров, переживающих завершение задачи/рестарт; тики монитора
привязаны к активному job'у в БД (нет job → нет тиков, нет статус-PATCH).
- **G4 — наблюдаемость:** лог однозначно показывает, **кто и почему** ставит deploy-статус
(caller/функция + причина), для будущей диагностики таких флаппов.
- Инструментальная локализация фактического актора флаппа на проде (воспроизведение на 061) и его
документирование (что это было) — в рамках выполнения задачи (developer/architect).
### Вне объёма
- Изменение конвейера стадий (`STAGE_TRANSITIONS`), состава `QG_CHECKS`, семантики machine-verdict
ключей (`deploy_status:`/`staging_status:`/…) — **не трогать**.
- Изменение рабочего deploy-цикла для **реально деплоящейся** задачи (Phase A→B→C, post-deploy
HEALTHY-окно) — поведение должно сохраниться 1:1 (регресс, AC-4).
- Поведение для не-self-hosting репозиториев (enduro-trails) — нулевая регрессия.
- Архитектурное решение «где именно поставить гард» (на уровне setter'а в `plane_sync` vs на уровне
вызывающего в `stage_engine` vs реконсилятор) — определяет **архитектор** в `06-adr/`.
## 3. Заинтересованные стороны
- **Заказчик/репортёр:** Слава (владелец) — обнаружил на ORCH-061 09.06.
- **Затрагивает:** всех наблюдателей доски Plane проекта ORCH (ложная индикация); лимиты Plane API
(вечный флапп жжёт вызовы под общим бот-токеном).
- **Принимает результат:** Owner / CI на финальной стадии конвейера.
- **Особый риск:** self-hosting — правка идёт в инструмент, обслуживающий прод всех проектов из
общего инстанса; рабочий deploy-цикл нельзя сломать.
## 4. Бизнес-требования (BR)
- **BR-1** — Терминальная задача (БД `stage=done`, 0 активных job'ов), выставленная в Plane=`Done`,
**остаётся `Done`** и не получает авто-переходов в `Awaiting Deploy`/`Monitoring after Deploy`.
- **BR-2** — Любой источник синхронизации (реконсилятор, монитор, прямой вызов setter'а deploy-статуса)
для задачи с БД=`done` приводит Plane к **`Done` идемпотентно**, а не к промежуточному deploy-статусу;
повторные срабатывания не качают маятник.
- **BR-3** — Post-deploy-monitor имеет **детерминированный конец** (HEALTHY / исчерпание N тиков → Done,
или DEGRADED → Blocked+freeze) и после завершения **не производит ни одного** последующего
статус-PATCH для этой задачи; не оставляет таймера/состояния, переживающего завершение или рестарт.
- **BR-4** — Тики post-deploy-monitor **привязаны к активному job'у** в таблице `jobs`: нет активного
job'а для задачи → нет тиков → нет статус-PATCH. «Зомби»-монитор (тики без соответствующего активного
job'а) исключён.
- **BR-5** — Для **реально деплоящейся** задачи (063-подобной) deploy-окно
`Awaiting → Deploying → Monitoring → Done` работает в точности как раньше (нет регресса).
- **BR-6** — Каждый вызов, выставляющий deploy-статус, оставляет в логе однозначную запись **кто
(функция/путь) и почему** ставит статус (наблюдаемость для будущей диагностики флаппов).
- **BR-7** — Фактический источник флаппа на проде локализован и **задокументирован** (что это было)
в `06-adr/` и/или `CHANGELOG.md`.
## 5. Нефункциональные требования (NFR)
- **NFR-1 — never-raise:** вся новая логика (гарды/терминал-скип/идемпотентность) не бросает
исключений в горячих путях; сетевая ошибка Plane при сверке статуса → безопасная деградация
(не флаппить и не падать), а не блокировка конвейера всех проектов.
- **NFR-2 — self-hosting безопасность:** не перезапускать/не ронять прод-контейнер; не трогать
`main`/force-push/прод-деплой; правка не меняет рабочий критический путь self-deploy.
- **NFR-3 — обратимость:** поведение под kill-switch (или иным обратимым флагом) — при выключении
возврат к прежнему поведению; нулевая регрессия для не-self репозиториев.
- **NFR-4 — restart-safe:** состояние монитора/гардов корректно после рестарта контейнера (нет
«воскрешения» тиков для уже завершённой задачи).
- **NFR-5 — `pytest tests/ -q` зелёный**; `STAGE_TRANSITIONS` / `QG_CHECKS` / machine-verdict ключи /
схема БД (если без миграции) — без изменений или строго аддитивно.
## 6. Допущения и ограничения
- Допущение: статусы `Monitoring after Deploy` / `Awaiting Deploy` существуют в Plane-проекте ORCH
как реальные статусы (иначе alias-fallback маппит их на базовые UUID — это часть диагностики
терминал-детекта).
- Допущение: бот-токен орка (`daf4d3f4-…`) — единственный актор переходов; внешняя Plane-automation
под другим токеном считается отдельной гипотезой и проверяется при локализации (H-внешнее).
- Ограничение: установленные факты выше **не изобретать** — они верифицированы на проде; точный
актор флаппа требует инструментального воспроизведения (фикс — после локализации).
- Ограничение: правка строго в зоне self-hosting deploy/post-deploy/sync; конвейер и гейты неизменны.
## 7. Критерии успеха
Терминальная задача стабильно держит `Done` ≥10 мин без авто-переходов (AC-1); любой sync для done
идемпотентно сходится к `Done` (AC-2); post-deploy-monitor завершается детерминированно и не
оставляет тиков/таймеров (AC-3); рабочий deploy-цикл 063-подобной задачи не регрессирует (AC-4);
never-raise + зелёный pytest + источник флаппа задокументирован (AC-5). Детальные PASS/FAIL — в
`03-acceptance-criteria.md`.
## 8. Риски
- Гард терминал-скипа поставлен слишком широко → подавит легитимный `Monitoring` у реально
деплоящейся задачи (регресс AC-4). Митигировать тонкой привязкой к БД `stage=done` + активность job.
- Фактический актор флаппа окажется внешней Plane-automation (вне кода орка) → код-фикс не закроет
G1 полностью; нужно зафиксировать в ADR и, при необходимости, защититься идемпотентным схождением
к Done (BR-2) как буфером.
- Детали — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,129 @@
---
work_item: ORCH-094
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-094 — устранение флаппа deploy-статусов у терминальной (done) задачи
Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода ветки.
> Архитектурное обоснование (ГДЕ ставить гард: setter `plane_sync` vs caller `stage_engine` vs
> реконсилятор) — задача архитектора (`06-adr/`). Здесь — ЧТО должно выполняться и ГДЕ искать.
## 1. Сводка изменения
Задача с БД `stage=done` и 0 активных job'ов в Plane стабильно держит `Done`: нужно (а) закрыть
источник, который шлёт ей PATCH-и deploy-статусов (`Awaiting Deploy`/`Monitoring after Deploy`),
(б) сделать выставление любого **deploy-фазового** статуса **терминал-aware / идемпотентным**
для задачи, чья БД-стадия терминальна (`done`/`cancelled`), любой sync/монитор/прямой вызов
сходится к `Done`, а не к промежуточному статусу, (в) гарантировать детерминированный конец
post-deploy-monitor с привязкой тиков к активному job'у (нет job → нет тиков), (г) добавить
наблюдаемость «кто/почему ставит deploy-статус».
Изменение **аддитивное, под обратимым флагом, never-raise**, в зоне self-hosting deploy/post-deploy/sync.
`STAGE_TRANSITIONS` / `QG_CHECKS` / machine-verdict ключи — **не трогаются**.
## 2. Задействованные модули / пути
| Путь | Действие | Зачем |
|------|----------|-------|
| `src/plane_sync.py` | изменить | Сеттеры `set_issue_awaiting_deploy` (~954), `set_issue_deploying` (~964), `set_issue_monitoring` (~974), `set_issue_done` (~913) — кандидат на единый терминал-aware гард (FR-2). Терминал-детект статуса (группа/UUID, ORCH-068) уже здесь. |
| `src/stage_engine.py` | изменить | Три писателя deploy-статуса: `advance_stage` стр. 404 (`set_issue_monitoring` на `deploy→done`), `_handle_self_deploy_phase_a` стр. 1218, `_handle_self_deploy_phase_b` стр. 1316. `run_post_deploy_monitor` (~16981850) — детерминированный конец, привязка к job. `arm_monitor`-вызов (~431). Логирование caller/причины (FR-4). |
| `src/post_deploy.py` | изменить (вероятно) | `arm_monitor` (~388411), маркеры `armed`/`series`/`done`, `enqueue_job("post-deploy-monitor", …)` — гарантия отсутствия «зомби»-тиков и привязки к активному job (FR-3). |
| `src/reconciler.py` | изменить (вероятно) | F-2 опрашивает только `[to_analyse, approved, rejected]` (стр. ~387). Нет схождения «done-задача на deploy-статусе → Done». Добавить идемпотентное схождение/терминал-детект для deploy-статусов (FR-1/FR-2) ИЛИ убедиться, что гард в setter'е делает это излишним. |
| `src/config.py` | изменить | Kill-switch/флаг новой логики (FR-5). |
| `src/webhooks/plane.py` | прочитать (диагностика) | `handle_issue_updated` (~129180): подтверждено, что для `Awaiting`/`Monitoring` логирует «no pipeline action» и не переотправляет — echo-loop исключён; править не требуется (если локализация не покажет иное). |
| `tests/test_*` | создать/изменить | Анти-регресс по FR-1…FR-5 (см. `04-test-plan.yaml`). |
| `CHANGELOG.md`, `docs/architecture/README.md`, `CLAUDE.md` | изменить | Документация = golden source; зафиксировать фикс + локализованный источник флаппа (BR-7). |
> **Трассировка маркеров (CLAUDE.md прав. 9):** перед правкой строк с маркерами `ORCH-066`/`ORCH-068`/
> `ORCH-086`/`ORCH-036`/`ORCH-059`/`ORCH-071`/`ORCH-088` прочитать их `06-adr/` и не сломать инвариант
> (особенно: deploy→done ставит `Monitoring`, монитор-close ставит `Done`; терминал-скип реконсилятора;
> post-deploy DEGRADED → freeze ORCH-088).
## 3. Функциональные требования
### FR-1 — Источник флаппа локализован и устранён
Инструментально воспроизвести флапп на ORCH-061 (или эквивалентной терминальной задаче), определить
**фактического актора** (функция/путь под бот-токеном орка ИЛИ внешняя Plane-automation) и устранить
его так, чтобы терминальная задача не получала deploy-статус-PATCH-ей.
- Зацепки (BR diagnostics): единственные code-писатели — `stage_engine.py:404/1218/1316`; реконсилятор
F-1 done-skip есть, F-2 эти статусы не перебирает; live-overlay `notifications.py` — read-only.
- Если актор — внешняя Plane-automation (вне кода орка), это **фиксируется в ADR** (BR-7) и закрывается
буфером FR-2 (идемпотентное схождение к Done гасит маятник на стороне орка).
- Привязка: BR-1, BR-7.
### FR-2 — Терминал-aware идемпотентность выставления deploy-статуса
Любая попытка выставить **deploy-фазовый** статус (`Awaiting Deploy`/`Deploying`/`Monitoring after
Deploy`) для задачи, чья БД-стадия **терминальна** (`stage IN ('done','cancelled')`), должна вместо
этого привести Plane к `Done` (для `done`) либо к корректному терминалу (для `cancelled`) —
идемпотентно. Повторные вызовы не качают маятник: уже-`Done` → no-op.
- Гард — терминал-aware (по БД-стадии задачи, не по живому Plane-статусу), чтобы НЕ подавлять
легитимный `Monitoring` у реально деплоящейся (нетерминальной) задачи (BR-5/AC-4).
- Реализация-кандидат (решает архитектор): единая точка в setter'ах `plane_sync` (требует доступа к
БД-стадии по `work_item_id`) ИЛИ в caller'ах `stage_engine`/`reconciler`. ТЗ требует **результат**:
done-задача сходится к Done из любого пути.
- never-raise: невозможность определить стадию/сетевая ошибка → безопасная деградация (не флаппить).
- Привязка: BR-1, BR-2.
### FR-3 — Детерминированный конец post-deploy-monitor + привязка тиков к активному job
- Монитор завершается детерминированно: HEALTHY+исчерпание `post_deploy_budget` тиков → `set_issue_done`
+ маркер `done`; DEGRADED → штатный путь (Blocked/freeze ORCH-088); после завершения — **ни одного**
последующего статус-PATCH (маркер `done` — идемпотентный страж, ~стр. 1729).
- Тик монитора **обязан** проверять, что задача не терминальна и для неё есть основание тикать (нет
активного основания/job → тик no-op, новый тик не ставится в очередь). «Зомби»-тик (тик без
соответствующего активного job'а/при БД=done) → немедленный no-op без статус-PATCH.
- Гарантировать, что `arm_monitor` не может быть вызван/перезапущен для задачи, уже находящейся в `done`,
способом, который заново ставит `Monitoring` (повторный `deploy→done` re-drive).
- restart-safe: после рестарта контейнера нет воскрешения тиков для завершённого окна.
- Привязка: BR-3, BR-4, NFR-4.
### FR-4 — Наблюдаемость выставления deploy-статуса
Каждый вызов, выставляющий deploy-фазовый статус, логирует структурно: **work_item, caller
(функция/путь), целевой статус, причина/триггер, БД-стадия задачи на момент вызова**. Достаточно,
чтобы по логу однозначно определить «кто и почему» при будущем флаппе. Терминал-aware-подавление
(FR-2) тоже логируется (что подавили и почему).
- Привязка: BR-6, G4.
### FR-5 — Обратимость и совместимость
- Новая логика — под kill-switch/флагом в `config.py` (env-override); `False` → прежнее поведение
1:1 (нулевая регрессия).
- Условность self-hosting, как ORCH-035/036/043/088: для не-self репозиториев — no-op / прежнее
поведение.
- Привязка: NFR-3, BR-5.
## 4. Изменения API
Нет новых внешних эндпоинтов конвейера. Допустимо (на усмотрение архитектора) аддитивное read-only
поле наблюдаемости в `GET /queue` (напр. блок `post_deploy`/`deploy_status_guard` со счётчиками
подавлений), по образцу существующих блоков `serial_gate`/`reconcile`/`reaper`. Не обязательно.
## 5. Изменения схемы БД
Ожидается **без миграции схемы**: терминал-aware гард читает существующую `tasks.stage`; привязка
тиков к job — существующая таблица `jobs`; состояние монитора — существующие sentinel-файлы
(`post_deploy.py`). Если архитектор сочтёт необходимым durable-счётчик/маркер — строго аддитивно
(`_ensure_column`, по образцу ORCH-088/090), без изменения существующих колонок.
## 6. Требования к новым/изменённым QG checks
**Нет.** `QG_CHECKS` и `check_*` (включая `check_deploy_status`/`check_staging_status`) — **не
трогаются**; machine-verdict ключи (`deploy_status:`/`staging_status:`/…) — байт-в-байт. ORCH-094 —
фикс индикации/идемпотентности sync, не гейт.
## 7. Совместимость / регресс
- **Kill-switch** (FR-5): выключение → прежнее поведение 1:1.
- **Регресс деплоя (AC-4):** рабочий цикл 063-подобной задачи `Awaiting→Deploying→Monitoring→Done`
сохраняется — гард срабатывает строго на терминальной БД-стадии, нетерминальная задача проходит
как раньше.
- **Не-self репозитории:** условность self-hosting → нулевая регрессия (enduro-trails).
- **`STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict/схема БД** — без изменений (или строго аддитивно).
- **never-raise / self-hosting безопасность:** не трогать `main`/force-push/прод-контейнер/детач-деплой.
- **Артефакты pipeline:** обновляются `CHANGELOG.md`, обзорные доки (`README.md`/`docs/architecture/
README.md`), `CLAUDE.md`; `06-adr/ADR-NNN-…` с локализованным источником флаппа (BR-7).

View File

@@ -0,0 +1,94 @@
---
work_item: ORCH-094
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-094 — флапп deploy-статусов у терминальной (done) задачи
Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что
считается провалом). Reviewer/CI проверяет их буквально по файлам репозитория и/или прод-проверкой.
---
## AC-1 — Терминальная задача стабильно держит Done
**Условие:** задача с БД `stage=done` и 0 активных job'ов, выставленная в Plane=`Done`, наблюдается
≥10 минут (воспроизводящий тест на 061-подобной фикстуре и/или прод-проверка на ORCH-061).
- **PASS:** за окно наблюдения **ни одного** авто-перехода в `Awaiting Deploy`/`Monitoring after
Deploy`; статус остаётся `Done`. В тесте: после выставления `Done` ни один кодовый путь орка не
порождает PATCH deploy-статуса для этой задачи.
- **FAIL:** зафиксирован хотя бы один авто-переход done-задачи в `Awaiting`/`Monitoring`, либо флапп
продолжается.
---
## AC-2 — Идемпотентное схождение к Done для done-задачи
**Условие:** для задачи с БД `stage IN ('done','cancelled')` инициируется любой источник sync
(реконсилятор-тик, монитор-тик, прямой вызов setter'а deploy-статуса).
- **PASS:** результат — `Done` (для `done`) / корректный терминал (для `cancelled`); промежуточный
deploy-статус (`Awaiting`/`Deploying`/`Monitoring`) **не** выставляется; повторный вызов на
уже-`Done` — no-op (без PATCH-маятника). Подавление логируется (что/почему).
- **FAIL:** sync для done-задачи выставляет промежуточный deploy-статус, либо повторные вызовы
качают `Done ⟷ deploy-статус`.
---
## AC-3 — Детерминированный конец post-deploy-monitor, без «зомби»-тиков
**Условие:** post-deploy-monitor отрабатывает свой жизненный цикл (HEALTHY до исчерпания
`post_deploy_budget` тиков, либо DEGRADED).
- **PASS:** по достижении HEALTHY/N-тиков → `set_issue_done` + маркер `done`; **после завершения —
0 последующих статус-PATCH** для этой задачи (тест: монитор отработал → последующих
`set_issue_*`-вызовов нет). Тик при БД=`done`/отсутствии активного основания → немедленный no-op
без PATCH. После рестарта контейнера тики завершённого окна не воскресают.
- **FAIL:** после завершения монитора фиксируется хотя бы один статус-PATCH; либо «зомби»-тик
выполняется без активного job'а/при БД=done и шлёт статус; либо `arm_monitor` повторно ставит
`Monitoring` уже-done-задаче.
---
## AC-4 — Регресс: рабочий deploy-цикл реально деплоящейся задачи
**Условие:** реально деплоящаяся 063-подобная задача проходит self-deploy.
- **PASS:** последовательность статусов `Awaiting Deploy → Deploying → Monitoring after Deploy →
Done` работает в точности как до ORCH-094; Phase A/B/C, merge-gate, post-deploy HEALTHY-окно,
freeze-на-DEGRADED (ORCH-088) — не затронуты; терминал-aware гард (FR-2) **не** подавляет
легитимный `Monitoring` у нетерминальной задачи.
- **FAIL:** любой шаг рабочего deploy-цикла нетерминальной задачи изменён/подавлён/сломан.
---
## AC-5 — Наблюдаемость, безопасность, документация, зелёный pytest
**Условие:** реализация завершена.
- **PASS:**
- Лог однозначно показывает **кто (функция/путь) и почему** ставит deploy-статус, и что/почему
подавлено терминал-aware гардом (FR-4).
- never-raise: новая логика не бросает исключений в горячих путях; сетевая ошибка Plane → безопасная
деградация. Не трогаются `main`/force-push/прод-контейнер/детач-деплой.
- `STAGE_TRANSITIONS` / `QG_CHECKS` / machine-verdict ключи — без изменений; новая логика под
kill-switch (`False` → прежнее поведение 1:1); не-self репозитории не затронуты.
- `pytest tests/ -q` зелёный; добавлены тесты по `04-test-plan.yaml`.
- **Источник флаппа задокументирован** (что это было) в `06-adr/ADR-NNN-…` + `CHANGELOG.md`;
обновлены `CLAUDE.md` / `docs/architecture/README.md` (golden source).
- **FAIL:** нет логирования caller/причины; new-логика бросает/без флага; тронуты гейты/verdict-ключи;
красный pytest; источник флаппа не задокументирован; затронут не-self репозиторий.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 |
| AC-2 | BR-2 / FR-2 |
| AC-3 | BR-3, BR-4 / FR-3 |
| AC-4 | BR-5 / FR-2, FR-5 |
| AC-5 | BR-6, BR-7 / FR-4, FR-5, NFR-1…NFR-5 |

View File

@@ -0,0 +1,97 @@
work_item: ORCH-094
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
title: "Тест-план: терминальная (done) задача не флаппит deploy-статусы, держит Done"
framework: pytest
scope: >
Покрывается: терминал-aware идемпотентность выставления deploy-статусов
(Awaiting/Deploying/Monitoring) для задач с БД stage=done/cancelled; детерминированный
конец post-deploy-monitor и отсутствие "зомби"-тиков/статус-PATCH после завершения;
привязка тиков монитора к активному job; наблюдаемость caller/причины; обратимость
(kill-switch) и регресс рабочего deploy-цикла реально деплоящейся задачи.
Вне покрытия: изменение STAGE_TRANSITIONS/QG_CHECKS/machine-verdict ключей (не трогаются);
поведение не-self репозиториев (проверяется как нулевая регрессия). Точный актор флаппа на
проде локализуется инструментально (developer) и фиксируется в ADR — на это отдельный
smoke/прод-чек, не unit.
notes: >
Полный регресс tests/ должен оставаться зелёным (pytest tests/ -q). Setter'ы Plane и сетевые
вызовы — мокать (никаких реальных PATCH в Plane из тестов). Регресс = любой авто-переход
done-задачи в deploy-статус, либо статус-PATCH после завершения монитора, либо подавление
легитимного Monitoring у нетерминальной задачи. Тесты опираются на фикстуры задач со стадиями
done/deploy и на счётчики вызовов set_issue_* (через мок).
tests:
- id: TC-01
type: unit
description: "deploy-статус для задачи с БД stage=done сходится к Done: попытка set_issue_monitoring/awaiting/deploying при terminal-стадии выставляет Done (или no-op, если уже Done), а не промежуточный статус."
module: tests/test_deploy_status_terminal_guard.py
expected: PASS
- id: TC-02
type: unit
description: "Идемпотентность: повторный вызов терминал-aware setter'а на уже-Done задаче — no-op (0 дополнительных PATCH), маятник Done<->deploy-статус не возникает."
module: tests/test_deploy_status_terminal_guard.py
expected: PASS
- id: TC-03
type: unit
description: "Нетерминальная задача (stage=deploy) не подавляется: set_issue_monitoring/awaiting/deploying проходит штатно (регресс AC-4)."
module: tests/test_deploy_status_terminal_guard.py
expected: PASS
- id: TC-04
type: unit
description: "Kill-switch выключен -> прежнее поведение 1:1 (терминал-aware гард не вмешивается); включён -> done-задача сходится к Done."
module: tests/test_deploy_status_terminal_guard.py
expected: PASS
- id: TC-05
type: unit
description: "never-raise: при невозможности определить БД-стадию / сетевой ошибке Plane сеттер деградирует безопасно (не флаппит, не бросает исключение)."
module: tests/test_deploy_status_terminal_guard.py
expected: PASS
- id: TC-06
type: unit
description: "post-deploy-monitor: после завершения окна (HEALTHY, ticks==budget -> set_issue_done + маркер done) последующих статус-PATCH для задачи нет (0 set_issue_* вызовов)."
module: tests/test_post_deploy_monitor_termination.py
expected: PASS
- id: TC-07
type: unit
description: "post-deploy-monitor тик при БД stage=done / отсутствии активного основания -> немедленный no-op без статус-PATCH и без постановки следующего тика ('зомби'-тик исключён)."
module: tests/test_post_deploy_monitor_termination.py
expected: PASS
- id: TC-08
type: unit
description: "arm_monitor не пере-арминг для задачи, уже находящейся в done: повторный deploy->done re-drive не выставляет Monitoring заново (маркер armed/done -> no-op)."
module: tests/test_post_deploy_monitor_termination.py
expected: PASS
- id: TC-09
type: unit
description: "Наблюдаемость: каждый вызов выставления deploy-статуса логирует work_item, caller/путь, целевой статус, причину и БД-стадию; подавление терминал-aware гардом тоже логируется."
module: tests/test_deploy_status_observability.py
expected: PASS
- id: TC-10
type: integration
description: "Реконсилятор/sync для задачи с БД=done и Plane=Monitoring приводит к Done идемпотентно (а не к промежуточному deploy-статусу) и не качает маятник на повторных тиках."
module: tests/test_reconciler_done_deploy_convergence.py
expected: PASS
- id: TC-11
type: integration
description: "Регресс рабочего deploy-цикла: реально деплоящаяся (нетерминальная) 063-подобная задача проходит Awaiting -> Deploying -> Monitoring -> Done без подавления (Phase A/B/C, post-deploy HEALTHY-окно как раньше)."
module: tests/test_self_deploy_cycle_regression.py
expected: PASS
- id: TC-12
type: integration
description: "Не-self репозиторий (enduro-подобный): нулевая регрессия — терминал-aware гард deploy-статусов инертен (условность self-hosting)."
module: tests/test_deploy_status_terminal_guard.py
expected: PASS

View File

@@ -0,0 +1,232 @@
---
work_item: ORCH-094
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-001: Terminal-window-aware гард выставления deploy-фазовых статусов Plane
Work Item: **ORCH-094** — терминальная (done) задача флаппит deploy-статусы в Plane
(`Awaiting Deploy ⟷ Monitoring after Deploy`), не держит `Done`.
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`**
(кросс-каттинг: правит общие сеттеры `plane_sync` + переупорядочивает маркированный блок
`next_stage == "done"` ORCH-021/066).
## Статус
Proposed
## Контекст
Сверено по коду ветки `feature/ORCH-094-…`:
- **Три code-писателя deploy-фазовых статусов** — все в `src/stage_engine.py`, все вызывают
тонкие сеттеры `src/plane_sync.py`, которые делегируют в общий `_set_issue_state_direct`
(PATCH issue.state; never-raise; **БД-стадию не читает**):
- `set_issue_awaiting_deploy` (Phase A, `stage_engine.py:1218`),
- `set_issue_deploying` (Phase B, `stage_engine.py:1316`),
- `set_issue_monitoring` (terminal-sync `deploy → done` для self-hosting, `stage_engine.py:404`).
- `set_issue_done` (`plane_sync.py:913`) — **терминальная цель**, отдельно.
- **Критический факт ordering'а:** в `advance_stage` строка **369** `update_task_stage(task_id, "done")`
пишет `tasks.stage='done'` **РАНЬШЕ**, чем строка **404** `set_issue_monitoring(...)`. То есть в
момент **легитимного** первого выставления `Monitoring after Deploy` задача в БД **уже `done`**.
Пост-деплой-окно ORCH-021 — это by-design индикация поверх уже-терминальной (`done`) задачи
(«ответственность ЗА `done`»). ⇒ **наивный гард «stage==done → редирект на Done» подавил бы
легитимный `Monitoring`регресс AC-4.**
- **Арм пост-деплой-монитора** (`stage_engine.py:431``post_deploy.arm_monitor`) выполняется
**ПОСЛЕ** строки 404. Sentinel `ARMED` пишется в `arm_monitor`; окно закрывается sentinel'ом
`DONE` (`post_deploy.mark_done`); идемпотентный страж `has_marker(...DONE)` в
`run_post_deploy_monitor` (~1729).
- **Симптом (верифицирован живьём на ORCH-061, task 47, done с 07.06):** Plane не держит `Done`
непрерывный флапп `Awaiting ⟷ Monitoring` парами каждые ~сек, 273 активности, само не затихает.
В БД **нет активного post-deploy-monitor** для task 47 (окно 15 мин давно закрыто); реконсилятор
F-1 пропускает `done`/`cancelled`, F-2 опрашивает только `[to_analyse, approved, rejected]`
механизма «привести застрявшую на deploy-статусе done-задачу обратно к Done» нет. Актор всех 273
переходов — бот-токен орка (`daf4d3f4-…`), т.е. PATCH-и шлёт **что-то под токеном орка**, не
привязанное к активной task/job. Точный актор подлежит инструментальной локализации (FR-1,
developer); фикс должен быть **буфером, гасящим маятник на стороне орка независимо от актора**.
**Почему «как есть» не годится:** сеттеры deploy-статусов терминал-слепы — любой повторный вызов
(стейл-job, двойной webhook, неизвестный внутренний путь под бот-токеном) перезаписывает `Done`
обратно на промежуточный deploy-статус, и наоборот, бесконечно. Нет ни идемпотентного схождения к
`Done` для терминальной задачи, ни наблюдаемости «кто/почему» ставит статус.
## Решение
### Сводка
Вводим **единый terminal-window-aware гард на самом низком чокпоинте** — на входе трёх
deploy-фазовых сеттеров `plane_sync`. Решение принимает **новый leaf-модуль
`src/deploy_status_guard.py`** (чистая, never-raise, config-gated логика; по образцу
`serial_gate.py`/`labels.py`/`cancel.py`), сеттеры лишь исполняют вердикт. Ключевой инвариант:
**deploy-фазовый статус легитимен ⇔ задача нетерминальна ИЛИ (`done` И активно пост-деплой-окно)**;
иначе — идемпотентное схождение к `Done`. Чтобы легитимный первый `Monitoring` на строке 404
проходил, **арм-блок переносится перед terminal-sync-блоком** (предикат «окно активно» становится
истинным до выставления `Monitoring`). Всё под kill-switch, аддитивно, в зоне self-hosting; реестры
конвейера не тронуты.
### D1 — Где гард: единый чокпоинт в deploy-фазовых сеттерах `plane_sync`
Гард ставится на входе **`set_issue_awaiting_deploy` / `set_issue_deploying` / `set_issue_monitoring`**
(а НЕ в caller'ах `stage_engine`). Это перехватывает **любой** путь к этим статусам — известные
(stage_engine), будущие и **неизвестный актор под бот-токеном** (если он проходит через код орка) —
одной точкой. `set_issue_done` **не гардится** (это цель схождения). Привязка: **FR-2, BR-1, BR-2**.
> Альтернатива «гард в caller'ах stage_engine» отвергнута: не ловит неизвестный/стейл путь, который
> и есть подозреваемый источник 061-флаппа; размазывает инвариант по трём местам. См. «Альтернативы».
### D2 — Предикат легитимности: терминал **И окно**, не только стадия
Вердикт `deploy_status_guard.decide(work_item_id, target_status) -> ALLOW | CONVERGE_DONE | SUPPRESS`:
1. `not settings.deploy_status_guard_enabled`**ALLOW** (kill-switch off ⇒ поведение 1:1).
2. `task = <lookup по work_item_id>`; `task is None`**ALLOW** (чужой/не наш issue — не вмешиваемся).
3. `not deploy_status_guard.applies(task.repo)`**ALLOW** (не-self репо ⇒ нулевая регрессия; для них
`Monitoring`/`Awaiting`/`Deploying` и так не выставляются — terminal-sync идёт сразу в `Done`).
4. `stage = task.stage`; `stage NOT IN ('done','cancelled')`**ALLOW** (нетерминальная задача —
легитимный рабочий deploy-цикл; **AC-4**).
5. `stage == 'cancelled'`**SUPPRESS** (не штампуем deploy-статус поверх терминала `cancelled`;
cancel-flow ORCH-090 уже привёл Plane к своему терминалу — гард лишь не затирает его).
6. `stage == 'done'`:
- `target == 'monitoring'` **И** `post_deploy.window_active(repo, work_item_id)`**ALLOW**
(легитимное пост-деплой-окно — `Monitoring` корректен; **AC-4**);
- иначе → **CONVERGE_DONE** (для `done` `Awaiting`/`Deploying` всегда спуриозны — Phase A/B
случаются строго **до** `deploy → done`; и `Monitoring` при закрытом/неарм'ленном окне —
спуриозен, как 061).
7. **Любое исключение / невозможность определить стадию****ALLOW** + `logger.warning`
(never-raise, fail-safe к прежнему поведению; **NFR-1**). БД-чтение локальное (SQLite) и надёжное —
в штатном случае стадия читается, маятник не возникает.
Сеттер исполняет вердикт: `ALLOW` → штатный PATCH; `CONVERGE_DONE``set_issue_done(work_item_id)`
(идемпотентно — уже-`Done` ⇒ no-op PATCH-эквивалент); `SUPPRESS` → ничего не патчим. Привязка:
**FR-2, BR-1, BR-2, AC-1, AC-2, AC-4**.
**Новый helper** `post_deploy.window_active(repo, wi) -> bool` = `has_marker(ARMED) and not
has_marker(DONE)` (never-raise; restart-safe — sentinel'ы на диске переживают рестарт; **NFR-4**).
### D3 — Перенос арм-блока перед terminal-sync (чтобы D2 пропускал легитимный первый `Monitoring`)
В `advance_stage`, внутри ветки `next_stage == "done"`, **арм-блок** (`post_deploy.arm_monitor`,
сейчас стр. 431) перемещается **выше** terminal-sync-блока (`set_issue_monitoring`, стр. 404). После
переноса в момент строки 404: `ARMED` уже записан, `DONE` отсутствует ⇒ `window_active==True`
вердикт **ALLOW** ⇒ легитимный `Monitoring` проходит как раньше. Re-drive `deploy → done` **после**
закрытия окна (`DONE` присутствует) ⇒ `window_active==False`**CONVERGE_DONE** (не воскрешает
`Monitoring`).
Перенос безопасен: `arm_monitor` лишь пишет sentinel + ставит отложенный job — не зависит ни от
Plane-статуса, ни от merge-lease (release остаётся после terminal-sync). Инварианты ORCH-021
(идемпотентный арм по `ARMED`) и ORCH-066 (`deploy → done` для self ⇒ `Monitoring`, не `Done`)
сохранены. Привязка: **AC-4, BR-5**; маркеры `ORCH-021`/`ORCH-066` (прочитаны: `06-adr/ADR-001`,
`adr-0010`).
> Альтернатива «bypass-флаг `force=True` на доверенном вызове 404 вместо переноса» отвергнута: плодит
> два определения «легитимности» и доверенный обход; перенос оставляет **один** предикат «окно активно».
### D4 — Харднинг пост-деплой-монитора: нет «зомби»-тиков/PATCH после закрытия окна
`run_post_deploy_monitor` (`stage_engine.py` ~1698): сохранить существующий идемпотентный страж
`has_marker(...DONE)` (~1729; первым — ранний `return` без PATCH/реэнкью). Аддитивно: тик
**no-op без PATCH и без перепостановки**, если задача стала терминальной аномально (`stage ==
'cancelled'` мид-окно → закрыть окно `mark_done`, без статус-PATCH). Перепостановка тика остаётся
строго при `HEALTHY and ticks < budget` — тики **привязаны к активному job'у** (тик и есть job; нет
job → нет тика). После закрытия окна (`DONE`) или исчерпания бюджета — **0 последующих** статус-PATCH;
любой стейл-вызов `set_issue_monitoring` теперь добивается гардом D2 (`window_active==False`
CONVERGE_DONE). `arm_monitor` уже идемпотентен по `ARMED` (повторный арм done-задачи → no-op). Привязка:
**FR-3, BR-3, BR-4, AC-3, NFR-4**.
### D5 — Наблюдаемость «кто/почему» (FR-4)
Каждый вердикт гарда логируется структурно одной записью: `work_item`, `caller` (короткая причина —
аддитивный BC-kwarg `reason: str | None = None` у трёх сеттеров; call-site передаёт напр.
`"advance:deploy->done"`/`"phase_a"`/`"phase_b"`/`"monitor-tick"`), `target_status`, `db_stage`,
`window_active`, итоговый вердикт (`ALLOW`/`CONVERGE_DONE`/`SUPPRESS`). Подавление/схождение
(`CONVERGE_DONE`/`SUPPRESS`) логируется **явно** («что подавили и почему»). Достаточно, чтобы по
логу однозначно атрибутировать будущий флапп. Привязка: **FR-4, BR-6, AC-5**.
### D6 — Обратимость, скоуп, флаги (FR-5)
`src/config.py` (по образцу ORCH-088/090):
- `deploy_status_guard_enabled: bool = True` — env `ORCH_DEPLOY_STATUS_GUARD_ENABLED` (kill-switch;
`False` → сеттеры терминал-слепы, поведение **1:1** прежнее).
- `deploy_status_guard_repos: str = ""` — env `ORCH_DEPLOY_STATUS_GUARD_REPOS` (CSV; **пусто →
self-hosting only**). `applies(repo)` (локальный, без сети) — единственная точка скоупа.
Дефолт `enabled=True` + `repos=""` ⇒ активен только для self-hosting (`orchestrator`), где deploy-фазовые
статусы вообще выставляются; не-self репо (enduro-trails) гард не трогает (D2 шаг 3). Привязка: **NFR-3,
BR-5, FR-5, AC-4, AC-5**.
### D7 — Что НЕ трогаем (инварианты)
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи
(`deploy_status:`/`staging_status:`/`security_status:`) — **байт-в-байт**. Схема БД — **без миграции**
(гард читает существующую `tasks.stage`; окно — существующие sentinel'ы `post_deploy.py`; привязка к
job — существующая таблица `jobs`). `main`/force-push/прод-контейнер/detached-деплой — **не трогаются**.
Рабочий критический путь self-deploy (Phase A→B→C, merge-gate, freeze-на-DEGRADED ORCH-088) —
сохранён 1:1. Реконсилятор F-1/F-2 — **без изменений** (гард на сеттере субсумирует «sync → Done»:
любой путь, дёрнувший deploy-сеттер для done-задачи, сходится к `Done`). Привязка: **NFR-2, NFR-5, AC-5**.
### D8 — Лукап задачи по `work_item_id` (реализационная заметка для developer)
Сеттеры принимают `work_item_id` (напр. `"ORCH-061"`). В `src/db.py` существующий
`get_task_by_plane_id` матчит `plane_id`/`plane_issue_id` (UUID-ы), **не** человекочитаемый
`work_item_id`. Developer добавляет минимальный **read-only** аксессор
`get_task_by_work_item_id(work_item_id)` (`SELECT * FROM tasks WHERE work_item_id = ?`; живой ряд
матчит точно — тумбстоны ORCH-090 имеют суффикс `#cancelled-<id>`), **без изменения схемы**. Один
локальный SELECT отдаёт и `repo`, и `stage` для D2.
## Альтернативы
- **Гард в caller'ах `stage_engine` (а не в сеттерах)** — отвергнуто: не ловит неизвестный/стейл
актор под бот-токеном (вероятный источник 061-флаппа), размазывает инвариант по трём врезкам,
слабее как буфер BR-2 «сходимость из любого пути».
- **Наивный гард «stage==done → редирект на Done» (без предиката окна)** — отвергнуто: подавляет
легитимный пост-деплой `Monitoring` (он by-design поверх уже-`done` задачи, стр. 369 < 404) ⇒
прямой регресс **AC-4**.
- **Bypass-флаг `force=True` на доверенном вызове 404** (вместо переноса арм-блока) — отвергнуто:
два определения легитимности + доверенный обход; перенос даёт один предикат «окно активно».
- **Активная сходимость в реконсиляторе (F-2 опрашивает Awaiting/Monitoring → set_issue_done)** —
отвергнуто как **основной** механизм (лишний Plane-polling, правка маркированного F-2). Гард на
сеттере уже гасит непрерывный флапп (каждый вызов актора сходится к `Done` за один цикл). Возможен
как **необязательный** follow-up для разовой зачистки quiescent-застрявшего статуса (вне scope —
такой кейс чинится разовым ручным sync; наблюдаемый дефект — непрерывный флапп, который буфер
покрывает).
- **Колонка-маркер в `tasks` для состояния окна** — отвергнуто: миграция на проде; sentinel'ы
`post_deploy.py` уже restart-safe (как ORCH-021/036).
## Последствия
- **+** Терминальная (`done`) задача стабильно держит `Done`: любой deploy-сеттер для неё сходится к
`Done` идемпотентно, маятник гаснет за один цикл независимо от актора (буфер BR-1/BR-2, AC-1/AC-2).
- **+** Легитимный пост-деплой `Monitoring` сохранён точно (предикат «окно активно» + перенос
арм-блока); рабочий deploy-цикл 1:1 (AC-4).
- **+** Наблюдаемость: лог однозначно атрибутирует «кто/почему» при будущем флаппе (AC-5).
- **+** Единый низкий чокпоинт ловит и неизвестный внутренний путь под бот-токеном.
- **** Один локальный SELECT (`tasks`) на каждый deploy-фазовый PATCH-вызов self-репо. Митигейшн:
читается тот же ряд, что даёт `repo` для `applies`; SQLite-чтение пренебрежимо против сетевого PATCH;
для не-self/выключенного флага — ранний ALLOW без лукапа окна.
- **** Если фактический актор флаппа — **внешняя** Plane-automation под другим токеном (вне кода
орка), code-фикс не закроет G1 полностью. Митигейшн: гард — буфер на стороне орка; локализация
актора (FR-1) и итог документируются (BR-7) — этот ADR фиксирует гипотезу «под бот-токеном орка».
- **** Перенос арм-блока меняет порядок внутри маркированного блока ORCH-021/066. Митигейшн:
инварианты обоих ADR проверены сохранёнными (D3); анти-регресс — TC-11 (рабочий цикл) + структурные
тесты.
- **Откат:** `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` → сеттеры терминал-слепы, поведение 1:1
прежнее (D2 шаг 1). Полный откат — revert ветки (перенос арм-блока + leaf + config + сеттер-врезки).
## Ссылки
- BRD: `docs/work-items/ORCH-094/01-brd.md`
- TRZ: `docs/work-items/ORCH-094/02-trz.md`
- Acceptance: `docs/work-items/ORCH-094/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-094/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`
- Сверено по коду: `src/stage_engine.py` (369/404/431/1218/1316/~1698-1729),
`src/plane_sync.py` (913/954/964/974, `_set_issue_state_direct`), `src/post_deploy.py`
(`arm_monitor`/`has_marker`/`ARMED`/`DONE`/`state_dir`), `src/reconciler.py` (F-1/F-2),
`src/config.py` (флаги ORCH-088/021/036), `src/db.py` (`get_task_by_plane_id`).
- Маркеры (прочитаны, не сломаны): ORCH-021 (`adr-0010` / `06-adr/ADR-001`), ORCH-066
(`06-adr/ADR-001-plane-status-model`), ORCH-086/068 (терминал-скип), ORCH-088 (freeze),
ORCH-090 (cancelled-терминал).

View File

@@ -0,0 +1,90 @@
---
work_item: ORCH-094
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-094 — terminal-window-aware гард deploy-статусов
Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: architecture
Формат: каждый риск — **вероятность × влияние**, причина, **митигейшн**, привязка к AC/ADR-решению.
---
## R-1 — Гард подавляет ЛЕГИТИМНЫЙ `Monitoring` у реально деплоящейся задачи (регресс AC-4)
- **Вероятность:** средняя (без точного предиката — высокая) · **Влияние:** высокое.
- **Причина:** `update_task_stage("done")` (стр. 369) выполняется **раньше** `set_issue_monitoring`
(стр. 404) ⇒ в момент легитимного `Monitoring` задача в БД уже `done`. Наивный гард
«stage==done → Done» затёр бы легитимную индикацию.
- **Митигейшн:** предикат **«терминал И НЕ активное окно»** (D2 шаг 6) + **перенос арм-блока перед
terminal-sync** (D3): `window_active==True` на стр. 404 ⇒ ALLOW. Анти-регресс**TC-11**
(рабочий цикл `Awaiting→Deploying→Monitoring→Done` без подавления) + **TC-03** (stage=deploy
проходит).
## R-2 — Фактический актор флаппа — внешняя Plane-automation (вне кода орка)
- **Вероятность:** низкая · **Влияние:** среднее (G1 закрыт не полностью).
- **Причина:** все 273 перехода — под бот-токеном орка; гипотеза H-внешнее не исключена до
инструментальной локализации (FR-1).
- **Митигейшн:** гард — **буфер на стороне орка** (BR-2): если PATCH идёт через код орка — гасится;
developer локализует актора (FR-1) и фиксирует в ADR/CHANGELOG (BR-7). Если актор реально внешний —
это документируется как known-limitation, гард остаётся защитой от внутренних путей.
## R-3 — Перенос арм-блока ломает инвариант ORCH-021/066
- **Вероятность:** низкая · **Влияние:** высокое (self-hosting прод).
- **Причина:** правка порядка внутри маркированного блока `next_stage == "done"`.
- **Митигейшн:** `arm_monitor` не зависит от Plane-статуса/merge-lease (пишет sentinel + ставит
отложенный job); merge-lease release остаётся после terminal-sync; идемпотентность арма по `ARMED`
и инвариант ORCH-066 (`deploy→done` self ⇒ `Monitoring`) сохранены (D3). Прочитаны `adr-0010` +
`06-adr/ADR-001-plane-status-model`. Тесты TC-06/TC-08 + TC-11.
## R-4 — `never-raise`-деградация маскирует флапп (fail-safe = ALLOW)
- **Вероятность:** низкая · **Влияние:** низкое.
- **Причина:** при ошибке лукапа стадии / сетевой ошибке гард делает ALLOW (прежнее поведение), что
в теории не гасит маятник.
- **Митигейшн:** БД-чтение — локальный SQLite (надёжно; ошибка редка); в штатном случае стадия
читается ⇒ сходимость работает. Деградация **логируется** `warning` (D5) ⇒ видно в диагностике.
NFR-1 приоритезирует «не падать/не блокировать конвейер всех проектов» над агрессивным подавлением.
Тест TC-05.
## R-5 — «Зомби»-тик пост-деплой-монитора после рестарта/стейл-job шлёт статус-PATCH
- **Вероятность:** низкая · **Влияние:** среднее.
- **Причина:** стейл-job `post-deploy-monitor` в очереди после закрытия окна/рестарта мог бы дёрнуть
`set_issue_monitoring`.
- **Митигейшн:** идемпотентный страж `has_marker(...DONE)` (ранний return без PATCH/реэнкью, ~1729) +
тик no-op при `cancelled` мид-окно (D4) + **гард D2** (`window_active==False` ⇒ CONVERGE_DONE).
restart-safe (sentinel'ы на диске). Тесты TC-06/TC-07.
## R-6 — Стоимость лукапа `tasks` на каждый deploy-PATCH
- **Вероятность:** низкая · **Влияние:** пренебрежимое.
- **Причина:** новый SELECT на каждый вызов deploy-сеттера self-репо.
- **Митигейшн:** тот же ряд даёт `repo` для `applies`; SQLite-чтение ничтожно против сетевого PATCH;
не-self/выключенный флаг → ранний ALLOW. Без кэша (корректность > микро-оптимизация).
## R-7 — Регресс не-self репозиториев (enduro-trails)
- **Вероятность:** очень низкая · **Влияние:** среднее.
- **Причина:** общий инстанс/БД; правка общих сеттеров `plane_sync`.
- **Митигейшн:** `applies(repo)` (D2 шаг 3, `deploy_status_guard_repos=""` → self-hosting only);
для не-self deploy-фазовые статусы и так не выставляются (terminal-sync сразу `Done`). Тест TC-12.
## R-8 — Лукап по `work_item_id` не матчит (нет аксессора)
- **Вероятность:** низкая · **Влияние:** низкое (деградирует в ALLOW).
- **Причина:** `get_task_by_plane_id` матчит UUID-ключи, не человекочитаемый `work_item_id`.
- **Митигейшн:** developer добавляет read-only `get_task_by_work_item_id` (D8, без миграции); при
промахе — ALLOW (never-raise). Тумбстоны ORCH-090 (`#cancelled-<id>`) не коллизируют с живым рядом.
---
## Сводка по инвариантам (не нарушены)
| Инвариант | Статус |
|-----------|--------|
| `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи | не тронуты (D7) |
| Схема БД | без миграции (read-only аксессор) (D7/D8) |
| `main` / force-push / прод-контейнер / detached-деплой | не тронуты (D7, NFR-2) |
| Рабочий self-deploy (Phase A→B→C, merge-gate, freeze ORCH-088) | 1:1 (D7, AC-4) |
| Реконсилятор F-1/F-2 | без изменений (гард субсумирует sync→Done) (D7) |
| Обратимость (kill-switch → 1:1) | `ORCH_DEPLOY_STATUS_GUARD_ENABLED` (D6) |

View File

@@ -0,0 +1,102 @@
---
verdict: APPROVED
work_item: ORCH-094
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-09
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-094
version: 1
---
# Review ORCH-094 — terminal-window-aware гард deploy-статусов
## Summary
PR устраняет флапп deploy-статусов у терминальной (`done`) задачи в Plane через единый
terminal-window-aware гард на входе трёх deploy-фазовых сеттеров `plane_sync`. Реализация
**точно следует** ADR-001 (D1D8): новый leaf `src/deploy_status_guard.py` (чистый, never-raise,
config-gated), перенос арм-блока перед terminal-sync, харднинг пост-деплой-монитора, наблюдаемость
через `reason`-kwarg. Все 4 оси проверки — без P0/P1.
Проверено по коду ветки: `deploy_status_guard.py`, `plane_sync.py` (врезка `_deploy_status_guarded` +
3 сеттера), `stage_engine.py` (перенос арм-блока D3 + zombie-tick guard D4 + `reason`-call-sites),
`post_deploy.py` (`window_active`), `db.py` (`get_task_by_work_item_id`), `config.py` (2 флага).
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have (информационно, вердикт не меняет)
- [ ] `post_deploy.window_active` при внутреннем исключении (`has_marker`-чтение sentinel'а) →
`False` → внутри `decide` шаг 6 даёт `CONVERGE_DONE`. Это **асимметрия** относительно общего
fail-safe-к-ALLOW контракта `decide` (шаг 7): транзиентная ошибка чтения sentinel'а в момент
легитимного первого `Monitoring` свела бы его к `Done` (индикация-глитч, не флапп). Поведение
**намеренное и задокументировано** (docstring `window_active`: «doubt → window closed → converge
to Done — safe-for-indication default»), безопасно к терминальному состоянию; SQLite/диск-чтение
локальное и надёжное. Оставлено как осознанный дизайн-выбор, фиксации не требует.
## Соответствие ТЗ (`02-trz.md` / `03-acceptance-criteria.md`)
- **FR-1 / AC-1** (источник флаппа локализован, done держит Done) — ✅ актор задокументирован
(BR-7: code-писатели `stage_engine.py:404/1218/1316`, F-2 не перебирает, live-overlay read-only;
гипотеза «под бот-токеном» в ADR), гард — буфер сходимости. Тесты TC-01/02/10.
- **FR-2 / AC-2** (терминал-aware идемпотентность) — ✅ `decide → ALLOW|CONVERGE_DONE|SUPPRESS`,
предикат «нетерминал ИЛИ (`done` И окно)», `done`-иначе → `set_issue_done` идемпотентно, повтор
на уже-`Done` → no-op. Тесты TC-01/02/12.
- **FR-3 / AC-3** (детерминированный конец монитора, нет зомби-тиков) — ✅ страж `has_marker(DONE)`
сохранён; добавлен `cancelled`-мид-окно → `mark_done` без PATCH и без перепостановки; тик ≡ job.
Тесты TC-06/07/08.
- **FR-4 / AC-5** (наблюдаемость) — ✅ BC-kwarg `reason` у 3 сеттеров; ровно одна структурная запись
на вердикт (`work_item`/`caller`/`target`/`db_stage`/`window_active`/`verdict`; converge/suppress →
WARNING). Тест TC-09 (полная атрибуция).
- **FR-5 / AC-4** (обратимость, регресс рабочего цикла) — ✅ kill-switch
`deploy_status_guard_enabled` (`False` → 1:1) + self-hosting-only по дефолту (`repos=""`);
нетерминальный `Awaiting/Deploying/Monitoring` проходит как раньше. Тесты TC-04/11/12 — особо
TC-11 (end-to-end `run_deploy_finalizer`: легитимный `Monitoring` НЕ свёрнут к Done).
## Соответствие ADR (`06-adr/ADR-001` + сквозной `adr-0028`)
- D1 (гард на входе сеттеров `plane_sync`, не в caller'ах) — ✅.
- D2 (предикат терминал **И** окно; 7 шагов) — ✅ реализован 1:1 в `decide`.
- D3 (перенос арм-блока выше terminal-sync) — ✅ подтверждён в diff `advance_stage`; merge-lease
release остаётся после terminal-sync; инварианты ORCH-021/066 сохранены.
- D4 (харднинг монитора) — ✅. D5 (наблюдаемость) — ✅. D6 (флаги) — ✅. D7 (что НЕ трогаем) — ✅
(проверено: `src/stages.py`/`src/qg/`/`src/reconciler.py` — нулевой diff; machine-verdict ключи
байт-в-байт). D8 (`get_task_by_work_item_id` read-only) — ✅.
- **Трассировка маркеров (CLAUDE.md прав. 9 / TRACEABILITY):** правка маркированного блока
`next_stage=="done"` (ORCH-021/066/043/088) — ADR прочитаны, инварианты не сломаны (deploy→done
self ⇒ Monitoring; монитор-close ⇒ Done; терминал-набор `{done,cancelled}`; merge-lease release
не сдвинут относительно terminal-sync). Слома инвариантов нет.
## Качество кода
- Leaf-модуль `deploy_status_guard.py` — чистый, never-raise (двойная защита: `decide` + wrapper
`_deploy_status_guarded`), нет рекурсии (`set_issue_done` не гардится), docstrings на всех публичных
функциях, образец `serial_gate`/`labels`/`cancel` выдержан.
- Тесты содержательные (не тривиальные): 5 новых файлов, TC-01..12; TC-11 — реальный прогон
`run_deploy_finalizer` с проверкой стадии и единственного `Monitoring`-PATCH; обновлены
анти-регресс-ассерты под `reason`-kwarg. `pytest tests/ -q`**1413 passed**.
## Документация
`src/` изменён → документация обновлена **в том же PR** (golden source соблюдён):
-`CHANGELOG.md` — детальная запись ORCH-094 (FR/AC/D-разбивка).
-`docs/architecture/README.md` — новый раздел «Terminal-window-aware гард deploy-статусов».
-`CLAUDE.md` — врезка в блок статусной модели Plane.
-`.env.example``ORCH_DEPLOY_STATUS_GUARD_ENABLED` / `_REPOS` с описанием.
-`docs/work-items/ORCH-094/06-adr/ADR-001-…md` (work-item) + сквозной
`docs/architecture/adr/adr-0028-…md` (кросс-каттинг) — оба присутствуют.
- ✅ Обзорные доки (ORCH-079): PR — баг-фикс индикации, не закрывает пункт `README.md`
«Известные ограничения»; обновления корневого `README.md` не требуется.
Документация полная и согласована с реализацией. Расхождений код ↔ доки не найдено.

View File

@@ -0,0 +1,84 @@
---
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
work_item: ORCH-094
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-09
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-094
---
# Test Report — ORCH-094 — terminal-window-aware гард deploy-статусов
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-09
- Worktree (база прогона): `/repos/_wt/orchestrator/feature_ORCH-094-bug-done-deploy-plane-awaiting`
- Ветка: `feature/ORCH-094-bug-done-deploy-plane-awaiting`
- HEAD: `11de318` (поверх `3738888 fix(deploy): terminal-window-aware guard … (ORCH-094)`)
- Review: `12-review.md``verdict: APPROVED` (P0/P1 — нет).
> Прогон выполнен из worktree ветки задачи (не из общего `/repos/orchestrator`) — анти-гонка checkout.
## Smoke API (read-only)
| Проверка | Результат |
|----------|-----------|
| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | PASS — отвечает, отдаёт `active_tasks` |
| `GET /queue` | PASS — блок `serial_gate` присутствует (ORCH-088), `auto_labels` присутствует (ORCH-089) |
Деструктивные операции не выполнялись (read-only smoke).
## Результаты (покрытие тест-плана `04-test-plan.yaml` ↔ `03-acceptance-criteria.md`)
| TC ID | Тип | Описание | AC | Тест | Результат |
|-------|-----|----------|----|------|-----------|
| TC-01 | unit | done-задача сходится к Done (monitoring/awaiting/deploying при terminal → Done/no-op) | AC-2 | `test_deploy_status_terminal_guard::test_tc01_*` | PASS |
| TC-02 | unit | Идемпотентность: повтор на уже-Done → no-op, нет маятника | AC-2 | `test_deploy_status_terminal_guard::test_tc02_idempotent_no_pendulum` | PASS |
| TC-03 | unit | Нетерминальная (stage=deploy) не подавляется (регресс) | AC-4 | `test_deploy_status_terminal_guard::test_tc03_non_terminal_not_suppressed` | PASS |
| TC-04 | unit | Kill-switch: off → 1:1 прежнее; on → done сходится к Done | AC-5 | `test_deploy_status_terminal_guard::test_tc04_kill_switch` | PASS |
| TC-05 | unit | never-raise: неизвестная стадия / ошибка БД → безопасная деградация | AC-5 | `test_deploy_status_terminal_guard::test_tc05_*` | PASS |
| TC-06 | unit | После завершения окна монитора (HEALTHY, ticks==budget) → 0 последующих PATCH | AC-3 | `test_post_deploy_monitor_termination::test_tc06_clean_finish_then_no_more_patches` | PASS |
| TC-07 | unit | Тик при БД=done/cancelled / нет основания → no-op без PATCH и без перепостановки | AC-3 | `test_post_deploy_monitor_termination::test_tc07_*` | PASS |
| TC-08 | unit | `arm_monitor` не пере-арминг для done; re-drive не выставляет Monitoring заново | AC-3 | `test_post_deploy_monitor_termination::test_tc08_*` | PASS |
| TC-09 | unit | Наблюдаемость: лог work_item/caller/target/reason/db_stage; подавление логируется | AC-5 | `test_deploy_status_observability::test_tc09_*` | PASS |
| TC-10 | integration | Реконсилятор/sync для done+Plane=Monitoring → Done идемпотентно, без маятника | AC-2 | `test_reconciler_done_deploy_convergence::test_tc10_repeated_sync_converges_no_pendulum` | PASS |
| TC-11 | integration | Регресс рабочего цикла: нетерминальная задача Awaiting→Deploying→Monitoring→Done не подавлена | AC-4 | `test_self_deploy_cycle_regression::test_tc11_*` | PASS |
| TC-12 | integration | Не-self репо (enduro-подобный): гард инертен (условность self-hosting) | AC-4/AC-5 | `test_deploy_status_terminal_guard::test_tc12_*` | PASS |
**Все 12 TC выполнены и сопоставлены с критериями приёмки. Непокрытых TC нет.**
Покрытие AC:
- **AC-1** (done держит Done; нет авто-перехода в Awaiting/Monitoring) — TC-01/02/10 ✅
- **AC-2** (идемпотентное схождение к Done) — TC-01/02/10/12 ✅
- **AC-3** (детерминированный конец монитора, нет зомби-тиков) — TC-06/07/08 ✅
- **AC-4** (регресс рабочего deploy-цикла нетерминальной задачи) — TC-03/11/12 ✅
- **AC-5** (наблюдаемость, kill-switch, never-raise, зелёный pytest) — TC-04/05/09 + полный регресс
## Вывод pytest
Целевые модули ORCH-094:
```
tests/test_deploy_status_terminal_guard.py ........... (11)
tests/test_post_deploy_monitor_termination.py ..... (5)
tests/test_deploy_status_observability.py ... (3)
tests/test_reconciler_done_deploy_convergence.py . (1)
tests/test_self_deploy_cycle_regression.py .. (2)
======================== 22 passed, 1 warning in 1.43s =========================
```
Полный регресс (`pytest tests/ -v --tb=short`):
```
======================= 1413 passed, 1 warning in 44.34s =======================
```
> Единственное предупреждение — PydanticDeprecatedSince20 (class-based config в `src/config.py`),
> не связано с ORCH-094, не является ошибкой.
## Итог
**PASS** — полный регресс зелёный (1413 passed), все 12 TC из `04-test-plan.yaml` выполнены,
сопоставлены с AC и зелёные; smoke API (`/health`, `/status`, `/queue` c блоком `serial_gate`) OK.
Задача переходит на стадию `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-094
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,30 @@
---
staging_status: SUCCESS
work_item: ORCH-094
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-09
model_used: claude-opus-4-8
timestamp: 2026-06-09T20:38:21Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` stand (8501), run canonically
inside the container via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`.
Exit code **0**`staging_status: SUCCESS`. All REAL pipeline checks passed; the only failures are
the two known sandbox-infra checks (C9a/C9b), waived under ORCH-061 tolerance.
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
## Results
- **Block A (SMOKE)**: PASS — A1 `/health`→200 `status=ok`; A2 `/queue`→200 (counts/max_concurrency/resilience); A3 `ORCH_STAGING=true`.
- **Block B (ACCESS)**: PASS — B4 Plane sandbox accessible (sandbox=YES); B5 Gitea `orchestrator-sandbox` accessible, push=true; B6 registry isolation OK (sandbox present, prod ET/ORCH absent).
- **Block C (E2E, mode=stub)**: C7 create issue in Plane SANDBOX PASS; C8 trigger pipeline via `/webhook/plane` PASS; **C9a/C9b FAIL — waived** (sandbox-infra: SANDBOX bot-accounts not members of the sandbox project; not a pipeline regression). CLEANUP: Plane issue deleted (HTTP 204), no branch to delete.
REAL failed: none.
Result: 8/10 checks PASS (2 waived sandbox-infra).

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: карточка трекера застывает — HTML-инъекция «<1м» в render_task_tracker (parse_mode=HTML)
Work Item ID: ORCH-095
## Description
TBD

View File

@@ -0,0 +1,154 @@
---
work_item: ORCH-095
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-095 — HTML-инъекция «<1м» в render_task_tracker застывает live-карточку
Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
Live-трекер задачи (`src/notifications.py::render_task_tracker`) — **основной канал
видимости конвейера для оператора**. Слава узнаёт состояние каждой задачи по её единственной
карточке в Telegram (инвариант «одна карточка на задачу», ORCH-042/067/087). Если карточка
перестаёт обновляться — оператор слепнет: задача реально идёт/завершилась, а карточка врёт.
**Установленный факт (воспроизведён детерминированно 09.06, сырой ответ Telegram).**
Прямой вызов `editMessageText` для застрявшей карточки ORCH-093 (`message_id 18854`) вернул:
```
400 Bad Request: can't parse entities: Unsupported start tag "1м" at byte offset 500
```
В тексте карточки на позиции ~379 присутствует подстрока `<1м · …` — длительность стадии
«меньше одной минуты», которую `_fmt_minutes` (`src/notifications.py:288-289`) рендерит как
литерал **`<1м`**. Карточка отправляется с `parse_mode=HTML` (`editMessageText`,
`notifications.py:175`). Telegram трактует `<1м` как **открывающий HTML-тег** → парсинг падает
с `400``edit_telegram` возвращает `EDIT_FAILED``update_task_tracker` по ветке
`EDIT_FAILED` (`notifications.py:733-739`) делает `return`, **не** отправляя новую карточку
(защита от дублей, ORCH-087) → карточка **застывает** на стейте, где `<1м` впервые попал в текст.
**Цепочка отказа** (по коду):
`_fmt_minutes(<60s) → "<1м"` → интерполируется в HTML без экранирования → `editMessageText`
`400 can't parse entities``edit_telegram → EDIT_FAILED``update_task_tracker` ранний
`return` → карточка не обновляется до конца жизни задачи.
**Почему проявляется не на каждой задаче.** Баг ловится **только** когда хотя бы одна
длительность стадии < 1 мин (`seconds < 60`) и эта строка попадает в текст, который затем
редактируется. Карточки ORCH-090/091 редактировались успешно (на момент `edit` в их тексте
`<1м` не было); ORCH-093 — упала. Это объясняет «плавающую» природу симптома.
**Корневой класс дефекта — шире одного `<1м`.** Текст карточки собирается с `parse_mode=HTML`
из смеси (а) намеренной разметки-обёртки (`<a href>` номер задачи, `<b>`) и (б) подставляемых
**данных**. Намеренная разметка экранироваться **не должна**; данные — должны. Сейчас
экранирован только заголовок (`esc_title`, `notifications.py:428`) и href/label внутри
`plane_issue_link`. Прочие данные — длительности (`_fmt_minutes`), метрики токенов/стоимости
(`fmt_tokens`/`fmt_cost`), имя модели (`short_model_name`), статус-лейбл
(`_card_status_label`) — вставляются **без** `html.escape`. `<1м` — первый сработавший
экземпляр этого класса; задача закрывает класс, а не единичный символ.
## 2. Объём (scope)
### В объёме
- Устранить HTML-инъекцию в `render_task_tracker`: любые **данные**, попадающие в текст
карточки с `parse_mode=HTML`, не должны ломать парсер Telegram (`< > &` в данных
безопасны).
- Привести формат «длительность < 1 мин» к HTML-безопасному виду (экранированный `&lt;1м`
ИЛИ переформулировка `<1м``~0м` / `< 1 мин` с экранированием).
- Сохранить работоспособность **намеренной** разметки карточки (`<a href>` номер задачи,
жирный/прочее форматирование) — экранируются только данные, не обёртка.
- Восстановить обновления уже застрявших карточек (после фикса карточка возобновляет
обновления или переотправляется свежей).
- Юнит-покрытие HTML-безопасности всех динамических полей; зелёный регресс `pytest tests/ -q`;
запись в `CHANGELOG.md`.
### Вне объёма
- Изменение `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, схемы БД — **не трогаются** (баг
чисто в слое рендера уведомлений).
- Изменение режима трекера (`bump`/`edit`), логики леджера сирот (ORCH-087), статусной модели
ORCH-066, транспортных примитивов (`send_telegram`/`edit_telegram`/`delete_telegram`) —
кроме точечной HTML-безопасности самого текста.
- Редизайн раскладки/состава карточки, новые метрики, перевод строк.
- Изменение машинных вердиктов / frontmatter-контракта.
## 3. Заинтересованные стороны
- **Заказчик / репортёр:** Слава (оператор) — обнаружил баг 09.06 (карточка ORCH-093 застряла,
«по 91 уже нету»).
- **Затронуты:** все наблюдатели Telegram-трекера по **всем** проектам (self-hosting: общий
прод-инстанс обслуживает и enduro-trails — карточки их задач так же уязвимы при стадии < 1 мин).
- **Принимает результат:** reviewer/tester конвейера ORCH; финальная приёмка — оператор
(карточки снова обновляются в реальном времени).
## 4. Бизнес-требования (BR)
- **BR-1** — Карточка трекера, в тексте которой есть стадия длительностью < 1 мин, должна
успешно редактироваться (`editMessageText``200`, не `400 can't parse entities`). Источник
отказа — литерал `<1м` от `_fmt_minutes` — устранён. (⇒ G1, G2)
- **BR-2** — **Все** динамические значения, вставляемые в текст карточки с `parse_mode=HTML`
(длительности, метрики токенов/стоимости, имя модели/эффорта, имена/лейблы стадий,
статус-лейбл, заголовок задачи), HTML-безопасны: символы `< > &` в **данных** не
интерпретируются Telegram как разметка. (⇒ G1)
- **BR-3** — Длительность «меньше минуты» рендерится так, чтобы не выглядеть открывающим
HTML-тегом: экранированный `&lt;1м` **ИЛИ** переформулировка (`~0м` / `< 1 мин`) с
экранированием. Видимое оператору значение остаётся осмысленным («меньше минуты»). (⇒ G2)
- **BR-4** — **Регресс намеренной разметки:** кликабельный номер задачи (`<a href>`,
`plane_issue_link`) и любое форматирование-обёртка (`<b>` и т.п.) продолжают рендериться и
оставаться кликабельными/валидными — экранируются только подставляемые данные, не разметка. (⇒ G3)
- **BR-5** — Уже застрявшая карточка (класс ORCH-093) после деплоя фикса **возобновляет
обновления**: либо успешный `editMessageText` на следующем переходе стадии, либо
переотправка свежей карточки. Конкретный механизм восстановления (текст снова валиден →
edit проходит, ИЛИ классификация `can't parse entities` как пересоздаваемой) — решение
архитектора; бизнес-требование — карточка перестаёт быть «замёрзшей сиротой». (⇒ G... / AC-4)
## 5. Нефункциональные требования (NFR)
- **NFR-1 (never-raise):** `render_task_tracker` и весь путь уведомлений сохраняют контракт
«никогда не роняют конвейер» — любая ошибка рендера/экранирования деградирует к
fallback-строке, не исключение.
- **NFR-2 (нулевая регрессия разметки):** существующие зелёные тесты трекера
(`test_telegram_tracker.py`, `test_tracker_*`, `test_notifications_orphans.py`,
`test_notify_issue_links.py`) остаются зелёными; кликабельность номера и формат строк не
деградируют визуально (кроме намеренной смены вида «<1м»).
- **NFR-3 (self-hosting):** фикс — изменение **только** слоя рендера уведомлений; прод-контейнер
`orchestrator` не перезапускается в рамках стадий разработки; обязательна страховка
`deploy-staging` перед прод-деплоем. Машина стадий/гейты/схема БД не затрагиваются.
- **NFR-4 (совместимость):** изменение обратносовместимо по данным/схеме; не требует миграций;
применяется к новым рендерам сразу после деплоя.
## 6. Допущения и ограничения
- Карточка всегда отправляется с `parse_mode=HTML` (`send_telegram:58`, `edit_telegram:175`) —
это инвариант (ссылки/жирный требуют HTML); переход на `parse_mode=None`/MarkdownV2 **не**
рассматривается (сломает намеренную разметку, шире объёма).
- `fmt_tokens`/`fmt_cost` сейчас выдают только цифры/`.`/`k`/`M`/`$` (HTML-безопасно), но
требование BR-2 покрывает их **defence-in-depth** на случай будущих изменений формата.
- Telegram-лимит 48ч: карточки старше 48ч физически неудаляемы/неперезаписываемы — для них
восстановление недостижимо (known-limitation, унаследовано от ORCH-087); BR-5 относится к
карточкам в пределах окна.
- Источник `<1м``_fmt_minutes` (единственная функция, эмитящая литерал `<`); прочие данные
лишь потенциально опасны. Точка(и) внесения экранирования — решение архитектора (централизовать
в `_fmt_minutes`/на точке рендера/обёрткой-хелпером).
## 7. Критерии успеха
Карточка задачи со стадией < 1 мин успешно редактируется (нет `400 can't parse entities`);
все динамические поля HTML-безопасны; намеренная разметка (ссылка-номер, форматирование)
рендерится и кликабельна; застрявшие карточки возобновляют обновления; `never-raise` сохранён;
`pytest tests/ -q` зелёный; `CHANGELOG.md` обновлён. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- **Двойное экранирование** уже экранированных полей (`esc_title`, href/label в
`plane_issue_link`) → `&amp;lt;` в выводе. Митигировать на стадии архитектуры (экранировать
ровно один раз на источник данных).
- **Случайное экранирование разметки-обёртки** (`<a>`, `<b>`) → ссылки/жирный перестают
работать (регресс BR-4). Чёткая граница «данные vs обёртка».
- Изменение вида «<1м» меняет визуал карточки — согласовать формулировку с оператором (BR-3
допускает оба варианта).
- Детали/перечень — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,132 @@
---
work_item: ORCH-095
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-095 — HTML-безопасность динамических полей render_task_tracker
Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода.
> Архитектурное обоснование/выбор точки внесения экранирования — задача архитектора (06-adr).
## 1. Сводка изменения
Текст live-карточки (`render_task_tracker`) собирается с `parse_mode=HTML` из намеренной
разметки-обёртки (`<a href>` номер задачи, форматирование) и подставляемых **данных**. Сейчас
экранирован только заголовок (`esc_title`) и href/label внутри `plane_issue_link`; остальные
данные вставляются сырыми. Литерал `<1м` (длительность < 1 мин), возвращаемый `_fmt_minutes`,
Telegram парсит как открывающий тег → `editMessageText` падает `400 can't parse entities`
`edit_telegram → EDIT_FAILED``update_task_tracker` делает ранний `return` → карточка
застывает.
Требуется: (а) сделать формат «< 1 мин» HTML-безопасным; (б) гарантировать HTML-безопасность
**всех** данных, попадающих в текст карточки, **не** экранируя намеренную разметку-обёртку;
(в) обеспечить возобновление обновлений ранее застрявших карточек. Изменение локализовано в
слое уведомлений; машина стадий/гейты/схема БД не затрагиваются.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/notifications.py` | **изменить**`_fmt_minutes` (~280) и/или точки рендера в `render_task_tracker` (~355): HTML-безопасность данных |
| `src/notifications.py::render_task_tracker` | **изменить** — экранировать данные: длительности (`dur`), `status_label`, `model`/`effort`, метрики (defence-in-depth); НЕ трогать `num_html`, `_done_link`-разметку |
| `src/notifications.py::_card_status_label` (~1173) | **проверить/экранировать на потребителе** — статус-лейбл вставляется в `status_line` сырым |
| `src/notifications.py::edit_telegram` (~157) | **возможно изменить** (на усмотрение архитектора) — классификация `can't parse entities` для восстановления застрявших карточек (BR-5/AC-4) |
| `src/notifications.py::update_task_tracker` (~650) | **возможно затронуть** — ветка `EDIT_FAILED` vs пересоздание при перманентном parse-фейле (BR-5/AC-4) |
| `tests/test_telegram_tracker.py` (или новый `tests/test_tracker_html_escape.py`) | **создать/дополнить** — юнит HTML-безопасности всех динамических полей |
| `CHANGELOG.md` | **изменить** — запись о фиксе |
> Примечание: `fmt_tokens`/`fmt_cost`/`short_model_name` живут в `src/usage.py`; их выход
> сейчас HTML-безопасен (цифры/`.`/`k`/`M`/`$`/имя модели). Менять `src/usage.py` **не
> требуется** — defence-in-depth экранирование делается на потребителе в `notifications.py`.
## 3. Функциональные требования
### FR-1 — HTML-безопасный формат «меньше минуты» (⇒ BR-1, BR-3)
Длительность стадии < 60 с не должна порождать подстроку, которую Telegram трактует как
открывающий тег. Текущий `_fmt_minutes(seconds)` при `0 < seconds < 60` возвращает литерал
`"<1м"` (`notifications.py:288-289`). Поведение должно стать одним из (выбор — архитектор):
- экранированный вывод `&lt;1м` (видится оператору как `<1м`), **либо**
- переформулировка `~0м` / `< 1 мин` с последующим экранированием.
Инвариант: для **любого** входа `_fmt_minutes` (включая `0м`, `Nм`, `~Nм` от
`_capped_review_str`) результат, попав в `parse_mode=HTML`, не ломает парсер. `_fmt_minutes`
сохраняет never-raise (нечисловой/None вход → `0м`).
### FR-2 — HTML-безопасность всех данных карточки (⇒ BR-2)
Каждое **подставляемое значение-данные**, попадающее в текст `render_task_tracker`,
экранируется `html.escape(...)` ровно один раз перед вставкой в HTML-текст. Перечень полей-данных:
| Поле | Источник | Текущий статус |
|------|----------|----------------|
| Заголовок задачи | `title``esc_title` | уже экранирован ✓ (не дублировать) |
| Длительности стадий / BRD / done | `_fmt_minutes`, `_capped_review_str` | **дыра** (FR-1) |
| Статус-лейбл карточки | `_card_status_label``status_label` | **дыра** — экранировать |
| Имя модели | `short_model_name(last["model"])` | экранировать (defence-in-depth) |
| Эффорт | `_run_effort(last)` | экранировать (defence-in-depth) |
| Токены / стоимость | `fmt_tokens`/`fmt_cost` | HTML-безопасны; экранировать defence-in-depth |
| Метка «попытка N» / лейблы стадий | статические константы `_TRACKER_STAGES`/`_BRD_LABEL` | статичны; не требуют, но безопасно |
Инвариант FR-2: после рендера **ни один** символ `< > &`, пришедший из данных, не остаётся
неэкранированным в выходном тексте.
### FR-3 — Сохранность намеренной разметки-обёртки (⇒ BR-4)
Намеренные HTML-фрагменты **не** экранируются:
- `num_html` = `plane_issue_link(...)` — кликабельный `<a href>` номер задачи (внутри уже
экранированы href через `html.escape(url, quote=True)` и label);
- `link_for(...)` в строке «⏳ ждёт …» — намеренные ссылки;
- `_done_link(...)` — строка `🔗 PR #n · 📦 Внедрено`.
После фикса эти фрагменты рендерятся как валидный HTML и остаются кликабельными. Запрещено
двойное экранирование уже экранированных полей (`esc_title`, внутренности `plane_issue_link`).
### FR-4 — Возобновление обновлений застрявших карточек (⇒ BR-5)
После деплоя фикса карточка, ранее застрявшая на `400 can't parse entities`, должна
возобновить обновления. Достаточное условие по умолчанию: текст следующего рендера больше не
содержит небезопасной подстроки → `editMessageText` проходит (`200`) на ближайшем переходе
стадии. Опционально (решение архитектора): классифицировать перманентный parse-фейл в
`edit_telegram`/`update_task_tracker` как повод **переотправить** свежую карточку вместо
тихого `return` по `EDIT_FAILED` — но **без** регресса защиты от дублей (ORCH-087: транзиентные
фейлы по-прежнему НЕ плодят карточки). Если выбирается переклассификация — она должна отличать
перманентный `can't parse entities` от транзиентного (network/timeout/5xx).
### FR-5 — never-raise (⇒ NFR-1)
Все изменённые функции сохраняют контракт «никогда не роняют конвейер»: ошибка
экранирования/рендера → деградация к существующему fallback (`f"task-{task_id}"` /
пропуск строки), не исключение наружу.
## 4. Изменения API
Нет. HTTP-эндпоинты не добавляются/не меняются. (Внешний вызов — только исходящий
`editMessageText`/`sendMessage` к Telegram Bot API; контракт вызова не меняется, меняется
лишь безопасность `text`.)
## 5. Изменения схемы БД
Нет. Таблицы `tasks`/`agent_runs`/`tracker_messages` не затрагиваются; миграций нет.
## 6. Требования к новым/изменённым QG checks
Нет. `QG_CHECKS` / `check_*` / `STAGE_TRANSITIONS` / машинные вердикты не затрагиваются. Баг —
в слое рендера уведомлений, вне Quality Gate.
## 7. Совместимость / регресс
- **Обратная совместимость:** изменение чисто в формировании строки текста карточки; данные
БД, схема, режимы трекера (`bump`/`edit`), леджер сирот (ORCH-087), статусная модель
(ORCH-066) — без изменений.
- **Область раската:** все проекты на общем прод-инстансе (self-hosting) — фикс применяется к
каждому новому рендеру сразу после деплоя; не требует миграции/бэкфилла.
- **Kill-switch:** не требуется (исправление дефекта корректности, а не новая фича-ветка). Если
архитектор выбирает переклассификацию parse-фейла в `update_task_tracker` (FR-4 опц.) —
оценить целесообразность флага; по умолчанию изменение поведения минимально и безопасно.
- **Обратимость:** изменение откатывается обычным revert PR (только `notifications.py` +
тесты + CHANGELOG); прод-контейнер не требует ручных операций над данными.
- **Артефакты pipeline:** обновляются `12-review.md` (reviewer), `13-test-report.md` (tester),
`06-adr/ADR-001-*.md` (архитектор — выбор точки экранирования и стратегии FR-4),
`CHANGELOG.md`. Машинные вердикты гейтов — без изменений.
- **Self-hosting:** обязательна стадия `deploy-staging` (8501) перед прод-деплоем; прод
`orchestrator` не рестартуется в рамках разработки.

View File

@@ -0,0 +1,97 @@
---
work_item: ORCH-095
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-095 — HTML-инъекция «<1м» в render_task_tracker
Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам
репозитория.
---
## AC-1 — Стадия < 1 мин не ломает парсер Telegram
**Условие:** `render_task_tracker` для задачи, у которой хотя бы одна стадия длилась < 60 с,
выдаёт текст, безопасный для `parse_mode=HTML` (нет неэкранированного `<` в данных длительности).
- **PASS:** В выходном тексте подстрока длительности «меньше минуты» представлена как `&lt;1м`
(или переформулированный безопасный вид `~0м` / `< 1 мин` без сырого `<`); `editMessageText`
с этим текстом не вернул бы `400 can't parse entities: Unsupported start tag "1м"`. Юнит-тест
на `_fmt_minutes(30)` / `render_task_tracker(...)` подтверждает отсутствие сырого `<` от
длительности.
- **FAIL:** Текст содержит сырой `<1м` (или иной литерал `<`+нецифра) из данных длительности;
тест на парсинг/наличие сырого `<` падает.
---
## AC-2 — Все динамические поля карточки HTML-безопасны (юнит)
**Условие:** Существует юнит-тест, проверяющий, что каждое подставляемое **данные-поле**
`render_task_tracker` экранировано: длительность, токены, стоимость (`$`), заголовок с
спецсимволами `< > &`, статус-лейбл, имя модели/эффорт.
- **PASS:** Тест рендерит карточку с заголовком, содержащим `<`, `>`, `&` (напр.
`"A <b>x</b> & <1"`), и стадией < 1 мин; ассертит, что эти спецсимволы из ДАННЫХ
присутствуют в выводе только в экранированном виде (`&lt;`/`&gt;`/`&amp;`) и НЕ как
сырые теги; одновременно нет двойного экранирования (`&amp;lt;`).
- **FAIL:** Тест отсутствует, либо любое из перечисленных данных-полей попадает в текст без
экранирования, либо обнаруживается двойное экранирование.
---
## AC-3 — Регресс намеренной разметки (ссылка-номер, форматирование)
**Условие:** После фикса намеренная HTML-разметка карточки продолжает рендериться валидной и
кликабельной.
- **PASS:** Кликабельный номер задачи (`<a href="…">ORCH-095</a>` от `plane_issue_link`)
присутствует в выводе как валидный незаэкранированный `<a>`-тег; строки `🔗 PR #n`/`📦`
(`_done_link`) и любое форматирование-обёртка рендерятся; существующие тесты
`test_tracker_issue_link.py`/`test_notify_issue_links.py`/`test_telegram_tracker.py`
зелёные. Двойного экранирования href/label нет.
- **FAIL:** Номер задачи перестал быть кликабельным (`<a>` заэкранирован в `&lt;a&gt;`), либо
любой регресс-тест разметки красный.
---
## AC-4 — Застрявшая карточка возобновляет обновления
**Условие:** Карточка, ранее застрявшая на `400 can't parse entities` (класс ORCH-093), после
фикса снова обновляется.
- **PASS:** На следующем переходе стадии текст рендера больше не содержит небезопасной
подстроки → `editMessageText` проходит (`200`); ИЛИ (если выбрана стратегия FR-4-опц.)
перманентный parse-фейл классифицируется как повод переотправить свежую карточку, и
`update_task_tracker` отправляет новую. Поведение покрыто тестом (рендер валиден → edit-путь
не возвращает `EDIT_FAILED` из-за parse-ошибки).
- **FAIL:** После фикса карточка с прежним содержимым по-прежнему даёт `EDIT_FAILED` и не
обновляется/не переотправляется; либо защита от дублей (ORCH-087) сломана — транзиентный
фейл теперь плодит дубликаты карточек.
---
## AC-5 — never-raise, зелёный регресс, CHANGELOG
**Условие:** Контракт надёжности и гигиена изменения сохранены.
- **PASS:** `render_task_tracker`/`update_task_tracker`/`edit_telegram` не выбрасывают
исключение наружу при любом входе (включая «битый» заголовок/None); `pytest tests/ -q`
полностью зелёный; в `CHANGELOG.md` есть запись о фиксе ORCH-095; `STAGE_TRANSITIONS`/
`QG_CHECKS`/`check_*`/схема БД не изменены (diff их не трогает).
- **FAIL:** Любой тест в `tests/` красный; обнаружено непойманное исключение в пути рендера;
тронуты машина стадий/гейты/схема БД; нет записи в `CHANGELOG.md`.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1, BR-3 / FR-1 |
| AC-2 | BR-2 / FR-2 |
| AC-3 | BR-4 / FR-3 |
| AC-4 | BR-5 / FR-4 |
| AC-5 | NFR-1, NFR-2 / FR-5 |

View File

@@ -0,0 +1,95 @@
work_item: ORCH-095
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
title: "HTML-безопасность динамических полей render_task_tracker (фикс инъекции «<1м»)"
framework: pytest
scope: >
Покрывается: HTML-безопасность всех подставляемых данных в render_task_tracker
(длительности < 1 мин, токены/стоимость, имя модели/эффорт, статус-лейбл, заголовок со
спецсимволами), сохранность намеренной разметки (<a href> номер задачи, _done_link),
возобновление обновлений застрявшей карточки, never-raise. Вне покрытия: реальная сеть к
Telegram Bot API (мокируется httpx), изменения STAGE_TRANSITIONS/QG_CHECKS/схемы БД (не
трогаются).
notes: >
Тесты — изоляция от сети: httpx.post/get мокируются; БД — временная SQLite-фикстура с
задачей и agent_runs (стадия < 60 с). Полный регресс pytest tests/ -q должен оставаться
зелёным, включая существующие test_telegram_tracker.py / test_tracker_*.py /
test_notifications_orphans.py / test_notify_issue_links.py. Регрессом считается: красный
любой существующий тест трекера, заэкранированная намеренная разметка, двойное
экранирование, непойманное исключение в пути рендера.
tests:
- id: TC-01
type: unit
description: "_fmt_minutes для длительности < 60 с (напр. 30) не возвращает сырой '<1м': результат HTML-безопасен (&lt;1м либо переформулированный '~0м'/'< 1 мин' без сырого '<')."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-02
type: unit
description: "_fmt_minutes для граничных входов (0, None, нечисловое, ровно 60, большое значение) — never-raise и HTML-безопасный вывод во всех ветках."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-03
type: unit
description: "render_task_tracker для задачи со стадией < 1 мин: в выходном тексте нет неэкранированного '<' из данных длительности; подстрока длительности безопасна для parse_mode=HTML."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-04
type: unit
description: "render_task_tracker с заголовком, содержащим спецсимволы '<', '>', '&' (напр. 'A <b>x</b> & <1'): спецсимволы данных присутствуют только экранированными (&lt;/&gt;/&amp;), не как сырые теги; двойного экранирования (&amp;lt;) нет."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-05
type: unit
description: "Статус-лейбл (_card_status_label) и имя модели/эффорт, попадающие в текст карточки, экранированы (defence-in-depth): спецсимволы в них не ломают HTML."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-06
type: unit
description: "Метрики токенов/стоимости (fmt_tokens/fmt_cost) в карточке HTML-безопасны: '$' и числовой формат не порождают сырых тегов."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-07
type: unit
description: "Регресс намеренной разметки: кликабельный номер задачи (plane_issue_link -> <a href>) присутствует в выводе как валидный незаэкранированный <a>-тег; href/label не задвоены экранированием."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-08
type: unit
description: "Регресс _done_link: для завершённой задачи строка '🔗 PR #n · 📦 Внедрено' рендерится валидной (ссылочная разметка не экранирована)."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-09
type: integration
description: "update_task_tracker (edit-режим) с замоканным editMessageText: текст карточки со стадией < 1 мин принимается (мок ассертит отсутствие 'can't parse entities'-триггера, т.е. нет сырого '<1м' в payload text)."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-10
type: integration
description: "Возобновление застрявшей карточки (AC-4): после фикса валидный рендер проходит edit-путь без EDIT_FAILED из-за parse-ошибки; защита от дублей сохранена — транзиентный (network) фейл по-прежнему НЕ плодит новую карточку."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-11
type: unit
description: "never-raise: render_task_tracker на 'битых' входах (отсутствует задача, None-заголовок, нечисловые длительности) возвращает fallback-строку, не выбрасывает исключение."
module: tests/test_tracker_html_escape.py
expected: PASS
- id: TC-12
type: integration
description: "Полный регресс существующих тестов трекера (test_telegram_tracker.py, test_tracker_issue_link.py, test_tracker_status_line.py, test_notifications_orphans.py, test_notify_issue_links.py) остаётся зелёным после фикса."
module: tests/test_telegram_tracker.py
expected: PASS

View File

@@ -0,0 +1,209 @@
---
work_item: ORCH-095
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-001: HTML-безопасный рендер данных live-карточки трекера (устранение инъекции «<1м»)
Work Item: **ORCH-095** — HTML-инъекция `<1м` в `render_task_tracker` застывает live-карточку
Стадия: **architecture**
Сквозная регистрация: **N/A — локальное решение задачи.** Изменение целиком в слое рендера
уведомлений (`src/notifications.py`); новой стадии/QG/компонента/смены БД нет, инварианты
`STAGE_TRANSITIONS`/`QG_CHECKS`/схемы не затрагиваются → глобальный `adr-NNNN` не заводится
(прецедент — ORCH-091, такой же indication-only фикс рендера, тоже без сквозного ADR).
## Статус
Accepted
## Контекст
Live-карточка задачи (`src/notifications.py::render_task_tracker`) — основной канал видимости
конвейера для оператора, инвариант «одна карточка на задачу» (ORCH-042/067/087). Карточка
отправляется и редактируется с `parse_mode=HTML` (`send_telegram:58`, `edit_telegram:175`).
**Сверено по коду.** `_fmt_minutes(seconds)` (`notifications.py:280-290`) при `0 < seconds < 60`
возвращает литерал `"<1м"`:
```python
if seconds < 60:
return "<1м"
```
Эта подстрока интерполируется в HTML-текст карточки **без экранирования** (`_stage_line`:
`dur = _fmt_minutes(dur_sum)` → строка `f"✅ {label:<13} {dur} · …"`; те же `_fmt_minutes` /
`_capped_review_str` в строке BRD и в итоговой строке времени). Telegram трактует `<1м` как
открывающий HTML-тег → `editMessageText` отвечает `400 Bad Request: can't parse entities:
Unsupported start tag "1м"`. В `edit_telegram` неизвестный `400` классифицируется как
`EDIT_FAILED` (`notifications.py:203`), а `update_task_tracker` по ветке `EDIT_FAILED` делает
ранний `return` (анти-дубль ORCH-087) → **карточка застывает** (воспроизведено детерминированно
09.06 на ORCH-093, `message_id 18854`).
**Корневой класс шире одного `<1м`.** Текст карточки — смесь (а) намеренной разметки-обёртки
(`<a href>` номер задачи `num_html`, `link_for`, `_done_link`; заголовок уже экранирован как
`esc_title`, `notifications.py:428`) и (б) подставляемых **данных**. Экранирована только
категория-обёртка (href/label в `plane_issue_link` через `html.escape(..., quote=True)`) и
заголовок. Прочие данные — длительности (`_fmt_minutes`/`_capped_review_str`), статус-лейбл
(`_card_status_label``status_label`), имя модели (`short_model_name`), эффорт (`_run_effort`),
токены/стоимость (`fmt_tokens`/`fmt_cost`) — вставляются сырыми. `<1м` — первый сработавший
экземпляр класса «неэкранированные данные в HTML-тексте»; ТЗ требует закрыть класс, а не символ
(BR-2/FR-2).
«Как есть» не годится: симптом плавающий (ловится только когда хотя бы одна стадия длилась
< 60 с и её строка попадает в редактируемый текст), а отказ перманентный для конкретной карточки
до конца жизни задачи — оператор слепнет.
## Решение
### Сводка
Локализуем HTML-безопасность в **границе рендера**: каждое подставляемое **данные-значение**
экранируется `html.escape(...)` ровно один раз в точке интерполяции в `render_task_tracker`;
функции-источники данных (`_fmt_minutes`, `short_model_name`, `_run_effort`, `fmt_tokens`,
`fmt_cost`, `_card_status_label`) остаются **HTML-агностичными** (производят данные, не разметку).
Намеренная разметка-обёртка (`num_html`, `link_for(...)`, `_done_link`, уже-экранированный
`esc_title`) через экранирование **не** проходит. Литерал `<1м` в `_fmt_minutes` **сохраняется
как есть**: будучи экранированным на границе (`&lt;1м`), он рендерится оператору визуально
идентично (`<1м`) → видимый формат не меняется, согласование формулировки не требуется.
### D1 — Точка внесения экранирования: граница рендера, не источник данных (⇒ FR-1, FR-2)
Экранирование делается на **потребителе** (внутри `render_task_tracker`/`_stage_line`), а не
внутри функций-источников. Модель «слотов»: текст карточки собирается из слотов двух категорий —
- **Категория M (markup, НЕ экранировать):** `num_html` (`plane_issue_link`, внутри уже
экранированы href+label), `link_for(...)` в строке «⏳ ждёт …», `_done_link(...)`
(«🔗 PR #n · 📦 Внедрено»), `esc_title` (уже экранирован в строке 428).
- **Категория D (data, экранировать ровно один раз):** `dur` (`_fmt_minutes`/`_capped_review_str`),
`status_label` (`_card_status_label`), `model` (`short_model_name`), `effort` (`_run_effort`),
`in_tok`/`out_tok` (`fmt_tokens`), `cost` (`fmt_cost`), а также числовые `attempt` и static-лейблы
стадий (`_TRACKER_STAGES`/`_BRD_LABEL` — статичны и безопасны, но проходят через D ради
единообразного инварианта).
Рекомендуемая реализация (необязательна к буквальному следованию — выбор формы за developer):
завести тонкий модуль-локальный хелпер `def _esc(x): return html.escape(str(x))` (never-raise:
на исключении `str()` → пустая строка/исходный fallback) и обернуть им каждый D-слот в момент
присваивания, например `dur = _esc(_fmt_minutes(dur_sum))`, `model = _esc(short_model_name(...))`,
`status_label = _esc(status_label)`. Источники данных НЕ трогаются (в т.ч. `src/usage.py`
`fmt_tokens`/`fmt_cost`/`short_model_name` остаются как есть; defence-in-depth делается на
потребителе, как зафиксировано в ТЗ §2).
**Почему граница рендера, а не источник.** (1) Single-responsibility: `_fmt_minutes` и
`short_model_name` используются и вне HTML-контекста (логи, потенциально иные потребители) —
вшивать `&lt;` в их вывод сделало бы данные «грязными» в не-HTML-контексте. (2) Инвариант FR-2
формулируется и тестируется как свойство ОДНОЙ функции (`render_task_tracker`): «ни один символ
`< > &` из данных не остаётся неэкранированным в выходе» — а не как разрозненные контракты пяти
источников. (3) Экранирование на границе по построению исключает двойное экранирование: каждый
D-слот экранируется в ровно одной точке; M-слоты не экранируются вовсе.
**Инвариант D1:** видимый оператору формат всех D-полей не меняется (escape `<1м``&lt;1м`
рендерится как `<1м`; `~Nм`, `Nм`, токены/стоимость/модель символов `< > &` не содержат →
escape для них no-op).
### D2 — Сохранение `<1м` в источнике; формат-источник `_fmt_minutes` не меняется (⇒ FR-1, BR-3)
BR-3/FR-1 допускают два пути: (а) экранировать `&lt;1м`, либо (б) переформулировать (`~0м` /
`< 1 мин`). Выбираем **(а)**: `_fmt_minutes` продолжает возвращать `"<1м"`, безопасность даёт
escape на границе (D1). Это минимизирует поверхность изменения (никаких правок числовой/строковой
логики `_fmt_minutes`, `_capped_review_str`, тестов формата длительности) и сохраняет видимый
оператору вид `<1м` без согласования новой формулировки. `_fmt_minutes` сохраняет never-raise
(нечисловой/None → `0м`) без изменений.
### D3 — Defence-in-depth: экранируются ВСЕ D-поля, включая сейчас-безопасные (⇒ FR-2, BR-2)
Экранируются все поля категории D, в т.ч. сейчас гарантированно безопасные (`fmt_tokens`/
`fmt_cost` дают только цифры/`.`/`k`/`M`/`$`; `short_model_name``^claude-…$`). Стоимость
нулевая (escape безопасной строки — no-op), выгода — **структурный инвариант**: «каждый D-слот
карточки экранирован», который защищает от регрессии при будущей смене формата любого источника
(напр. если в имя модели/эффорта когда-нибудь попадёт пользовательский ввод). Тест AC-2 ассертит
инвариант, а не отдельные поля.
### D4 — FR-4 (восстановление застрявших карточек): авто-recovery следующим рендером; парс-фейл НЕ переклассифицируется (⇒ BR-5, FR-4)
Механизм восстановления — **достаточное условие по умолчанию** из FR-4: после деплоя фикса на
ближайшем переходе стадии `update_task_tracker` рендерит НОВЫЙ безопасный текст и вызывает
`edit_telegram(mid, new_text)` → Telegram отвечает `200` → застрявшая карточка (класс ORCH-093)
обновляется на месте. **Нового кода не требуется.**
Опциональную переклассификацию `can't parse entities` в `edit_telegram`/`update_task_tracker`
(переотправка свежей карточки вместо `EDIT_FAILED`) **отвергаем**:
- **Не помогает.** Если текст всё ещё небезопасен, `send_telegram` упадёт на том же `400`
идентично `editMessageText` (тот же `parse_mode=HTML`) и вернёт `None` → новой карточки нет.
После фикса D1D3 источник `can't parse entities` из НАШИХ данных структурно устранён, поэтому
отдельная ветка восстановления лечит несуществующий после фикса случай.
- **Риск.** Любое касание ветки `EDIT_FAILED`/леджера сирот рискует инвариантом ORCH-087
(транзиентный фейл НЕ должен плодить карточки). Минимальная поверхность безопаснее.
`edit_telegram`, `update_task_tracker`, `send_telegram`, леджер `tracker_messages`, режимы
`bump`/`edit`**не трогаются**. Known-limitation (унаследовано ORCH-087): для карточки, у
которой после фикса больше НЕ будет переходов стадии (задача завершилась до деплоя), повторного
рендера не возникнет → карточка остаётся замёрзшей; Telegram-лимит 48ч делает её неперезаписываемой
вне окна. BR-5 относится к карточкам в пределах окна с предстоящими переходами.
### D5 — Граница «данные vs обёртка»: M-слоты неприкосновенны, двойное экранирование запрещено (⇒ FR-3, BR-4)
`num_html` (`plane_issue_link`), `link_for(...)`, `_done_link(...)` и `esc_title` через `_esc`
НЕ проходят — остаются валидным HTML, номер задачи кликабелен. Внутренности `plane_issue_link`
(href `html.escape(url, quote=True)`, label `html.escape(work_item_id)`) уже экранированы — повторно
их не экранируем (иначе `&amp;lt;`, регресс AC-2/AC-3). Граница явная и тестируемая: D-слот → `_esc`;
M-слот → as-is.
### D6 — Трассировка и инварианты соседних маркеров (⇒ NFR-2, NFR-3)
`render_task_tracker`/`_stage_line` несут маркеры ORCH-042/067/087/091. Изменение ORCH-095
**аддитивно** к ним и обязано сохранить их инварианты: «одна карточка на задачу», леджер сирот и
анти-дубль (ORCH-087), отражение откатов + суммирование метрик `_stage_line` (ORCH-091), строка
Plane-статуса/кликабельный номер (ORCH-067). Поскольку ORCH-095 лишь оборачивает уже вычисленные
D-значения в `_esc`, не меняя ни состава строк, ни порядка, ни логики подавления/суммирования —
инварианты сохраняются по построению. Новые/изменённые строки помечаются маркером `ORCH-095`;
блок остаётся читаемым (не вводим 3+ новых маркера в один блок → сводный сквозной ADR не требуется,
TRACEABILITY анти-археология соблюдена).
## Альтернативы
- **Экранировать в источнике (`_fmt_minutes` возвращает `&lt;1м`)** — отвергнуто: пачкает данные
в не-HTML-контексте (логи), размазывает инвариант FR-2 по пяти функциям, усложняет защиту от
двойного экранирования (D1).
- **Переформулировать `<1м``~0м`/`< 1 мин`** — отвергнуто: меняет видимый оператору формат
(требует согласования), трогает логику/тесты `_fmt_minutes`; escape на границе достигает того же
при меньшей поверхности и нулевом визуальном изменении (D2).
- **Переключить карточку на `parse_mode=None`/MarkdownV2** — отвергнуто (вне объёма BRD §6):
сломает намеренную разметку (`<a href>` номер, `<b>`), MarkdownV2 требует экранирования ещё
большего набора символов.
- **Переклассификация `can't parse entities` → переотправка** — отвергнуто (D4): не помогает
(send падает идентично), риск инварианту анти-дубля ORCH-087.
## Последствия
- **+** Класс «неэкранированные данные в HTML-тексте карточки» закрыт целиком (BR-2); `<1м` и
любые будущие `< > &` из данных безопасны; карточка со стадией < 1 мин редактируется (`200`).
- **+** Структурный defence-in-depth инвариант («каждый D-слот экранирован»), тестируемый одним
свойством `render_task_tracker` (AC-2), устойчив к будущим сменам формата источников.
- **+** Видимый формат карточки и намеренная разметка (кликабельный номер, `_done_link`) без
изменений (BR-3/BR-4); никаких миграций/правок схемы/гейтов (NFR-3/NFR-4).
- **+** Застрявшие (в окне) карточки авто-восстанавливаются следующим рендером без нового кода
(BR-5).
- **** Точечная дисциплина «D-слот → `_esc`, M-слот → as-is» вносит точку для будущих ошибок
(можно забыть обернуть новый D-слот или по ошибке обернуть M-слот → двойное экранирование).
Митигейшн: тест-инвариант AC-2 (нет сырого `< > &` из данных И нет `&amp;lt;`) ловит обе
ошибки; явный реестр M-слотов в D5.
- **** Карточки задач, завершившихся до деплоя фикса, не восстанавливаются (нет будущего
рендера) — known-limitation, унаследовано ORCH-087/Telegram-48ч; вне управляемого.
- **Откат:** обычный revert PR (только `src/notifications.py` + тесты + `CHANGELOG.md` +
doc-правки); прод-контейнер `orchestrator` не требует ручных операций над данными/БД.
## Ссылки
- BRD: `docs/work-items/ORCH-095/01-brd.md`
- TRZ: `docs/work-items/ORCH-095/02-trz.md`
- Acceptance: `docs/work-items/ORCH-095/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-095/10-tech-risks.md`
- Сверено по коду: `src/notifications.py` (`_fmt_minutes:280-290`, `_capped_review_str:315-336`,
`render_task_tracker:355-610`, `_stage_line:467-507`, `_card_status_label:1173-1186`,
`plane_issue_link:932-949`, `_done_link:613-647`, `link_for:952-984`, `edit_telegram:157-207`,
`update_task_tracker:650-746`, `send_telegram:42-71`, `esc_title:428`)
- Инварианты соседей: ORCH-042/067 (карточка/номер), ORCH-087 (леджер сирот/анти-дубль),
ORCH-091 (откаты/суммирование `_stage_line`) — `docs/architecture/internals.md` §7

View File

@@ -0,0 +1,37 @@
---
work_item: ORCH-095
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-095 — HTML-безопасность данных live-карточки
Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **Двойное экранирование** уже-экранированных полей (`esc_title`, href/label внутри `plane_issue_link`) → `&amp;lt;` в выводе, визуальный мусор / регресс AC-2 | Сред. | Сред. | D1/D5: явный реестр M-слотов (markup) — через `_esc` НЕ проходят; `esc_title` остаётся единственной точкой escape заголовка; тест AC-2 ассертит отсутствие `&amp;lt;` |
| TR-2 | **Случайное экранирование разметки-обёртки** (`num_html`/`link_for`/`_done_link`) → `<a>` превращается в `&lt;a&gt;`, номер задачи перестаёт быть кликабельным (регресс BR-4/AC-3) | Низ. | Выс. | D5: M-слоты неприкосновенны; регресс-тесты `test_tracker_issue_link.py`/`test_notify_issue_links.py`/`test_telegram_tracker.py` зелёные; AC-3 проверяет наличие валидного `<a href>` в выводе |
| TR-3 | **Пропущен новый/существующий D-слот** (забыли обернуть `_esc`) → инъекция возвращается на другом поле | Низ. | Сред. | D3 defence-in-depth (обернуть ВСЕ D-поля разом); тест-инвариант AC-2 рендерит карточку с `< > &` в данных и ассертит отсутствие сырых спецсимволов из данных в выводе (свойство `render_task_tracker`, не пер-поле) |
| TR-4 | **Регресс never-raise**: `_esc(str(x))` на «битом» входе (объект с падающим `__str__`) бросает исключение в пути рендера (нарушение NFR-1) | Низ. | Сред. | FR-5: `_esc` сам never-raise (try/except → fallback-строка); путь `render_task_tracker`/`update_task_tracker` уже обёрнут `try/except` (строки 654/745); тест AC-5 с «битым» входом |
| TR-5 | **Застрявшая карточка не восстановилась** (задача завершилась до деплоя → нет будущего рендера) | Сред. | Низ. | Принятая known-limitation (D4): авто-recovery работает только при предстоящем переходе стадии; вне окна — Telegram-48ч (унаследовано ORCH-087); BR-5 ограничен карточками в окне |
| TR-6 | **Скрытая регрессия инвариантов соседних маркеров** (ORCH-087 анти-дубль, ORCH-091 суммирование `_stage_line`) при правке тела `_stage_line`/`render_task_tracker` | Низ. | Выс. | D6: изменение аддитивно (лишь оборачивает уже вычисленные значения в `_esc`), не меняет состав/порядок строк, логику подавления откатов и суммирования; полный регресс `pytest tests/ -q` зелёный (NFR-2) |
| TR-7 | **Self-hosting**: фикс деплоится на общий прод-инстанс (затронуты и enduro-trails) | Низ. | Сред. | NFR-3: изменение только слоя рендера; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; обязательная страховка `deploy-staging` (8501) перед прод-деплоем; прод `orchestrator` не рестартится в рамках разработки |
## Сводный вывод
Доминирующий класс рисков — **регресс рендера** (двойное экранирование / случайное экранирование
разметки / пропущенный D-слот), полностью покрываемый тест-инвариантом AC-2 + существующими
регресс-тестами трекера (AC-3/AC-5). Изменение **локализовано** в `src/notifications.py` (слой
рендера уведомлений), аддитивно к маркерам ORCH-042/067/087/091, не затрагивает машину стадий,
Quality Gates, схему БД, транспортные примитивы и режимы трекера. Остаточный риск для
прод-конвейера (self-hosting) — **низкий**: контракт never-raise сохранён, откат — обычный revert
PR без операций над данными. Эскалация `arch:major-change` **не требуется**; возврат в анализ
**не требуется** (ТЗ реализуемо без нарушения архитектурных принципов).

View File

@@ -0,0 +1,81 @@
---
verdict: APPROVED
work_item: ORCH-095
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-10
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-095
version: 1
---
# Review ORCH-095
## Summary
Фикс HTML-инъекции `<1м` в live-карточке трекера. Точечное, аддитивное, never-raise изменение
в индикативном слое (`src/notifications.py`): новый модуль-локальный хелпер `_esc(x) =
html.escape(str(x))` оборачивает каждый **data**-слот (`dur`/`_fmt_minutes`/`_capped_review_str`,
`status_label`, `model`, `effort`, токены/стоимость) ровно один раз на границе рендера
(`render_task_tracker`/`_stage_line`); **markup**-слоты (`num_html`/`link_for`/`_done_link`/
уже-экранированный `esc_title`) не трогаются. Источники (`_fmt_minutes`, `src/usage.py`) остаются
HTML-агностичными.
Проверены все четыре оси. Реализация соответствует ТЗ (FR-1…FR-5) и ADR-001 (D1…D6) буквально;
все 5 AC выполнены. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схема БД / транспорт нотификаций
— не тронуты (`git diff` пуст по `src/stages.py`, `src/qg/`, `src/stage_engine.py`, `src/db.py`).
Полный регресс `pytest tests/ -q` зелёный (**1437 passed**), новый `tests/test_tracker_html_escape.py`
(TC-01…TC-11) — зелёный.
**Соответствие осям:**
1. **ТЗ / AC** — FR-1/AC-1 (`<1м``&lt;1м` на границе, источник не меняется), FR-2/AC-2 (все
D-слоты экранированы — сверено по коду стр. 471/517-523/529/594/607/614-615/620-621/629),
FR-3/AC-3 (M-слоты не экранированы, двойного экранирования нет), FR-4/AC-4 (авто-восстановление
следующим рендером, без рискованной переклассификации `EDIT_FAILED` — корректно, защищает
инвариант ORCH-087), FR-5/AC-5 (never-raise + зелёный регресс + CHANGELOG). ✓
2. **ADR + трассировка** — реализация 1:1 с ADR-001 (escape на границе рендера, не в источнике;
M-слоты неприкосновенны). Блоки с маркерами ORCH-042/067/087/091 правлены аддитивно: код лишь
оборачивает уже вычисленные D-значения в `_esc`, не меняя состав строк/порядок/логику подавления
и суммирования — инварианты сохранены по построению. Сквозной `adr-NNNN` обоснованно не заведён
(локальный indication-only фикс). ✓
3. **Качество кода**`_esc` с docstring и never-raise; тесты содержательные (11 TC покрывают
каждый AC, включая регресс кликабельного `<a href>`-номера, `_done_link` и анти-дубль ORCH-087
на транзиентном фейле). ✓
4. **Документация** — обновлены в том же PR: `CHANGELOG.md`, `docs/architecture/README.md`
(блок Notifications/Live-tracker), `docs/architecture/internals.md` §7, ADR-001. ✓
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have
- [ ] `attempt` (`f"… попытка {attempt} …"`, ~стр. 572) и статичные лейблы стадий
(`_TRACKER_STAGES`/`_BRD_LABEL`) не проходят через `_esc`. ADR-001 D1 упоминает их в категории D
«ради единообразного инварианта», но `attempt` — всегда `int` (`len(agent_runs)`), а лейблы —
статичные константы → фактической поверхности инъекции нет, расхождение безвредно. Не блокирует;
можно унифицировать при будущем касании блока (оставляю на усмотрение, не требую правки).
## Документация
**Обновлена полностью в том же PR — требование правила 6 (CLAUDE.md) выполнено:**
- `CHANGELOG.md` — детальная запись ORCH-095 (механизм бага, D1D5, восстановление, трассировка, тесты).
- `docs/architecture/README.md` — компонент «Notifications / Live-tracker» дополнен абзацем ORCH-095
(data/markup-слоты, инвариант экранирования на границе, ссылка на ADR).
- `docs/architecture/internals.md` §7 — новая подсекция «HTML-безопасность данных карточки (ORCH-095)».
- `docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md` — архитектурное обоснование
(выбор точки экранирования, альтернативы, последствия).
Пункт `README.md` «Известные ограничения» данным фиксом не закрывается (баг корректности, не числился
в витрине ограничений) → обновление обзорной витрины (ORCH-079) не требуется.
**Вывод:** `src/` изменён — документация обновлена синхронно. P0 «документация не обновлена» не
применяется.

View File

@@ -0,0 +1,92 @@
---
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
work_item: ORCH-095
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-10
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-095
---
# Test Report — ORCH-095
Фикс HTML-инъекции «<1м» в live-карточке трекера (`render_task_tracker`). Прогон полного
регресса + профильной сюиты, smoke read-only API. Review-вердикт — `APPROVED` (12-review.md).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-10
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-095-bug-html-1-render-task-tracker`
(ветка `feature/ORCH-095-bug-html-1-render-task-tracker` — код именно этой задачи, не общий чекаут)
## Smoke API (read-only, прод-контейнер не трогается)
- `GET /health``{"status":"ok","service":"orchestrator"}`
- `GET /status` → активная задача ORCH-095 (id=80) на стадии `testing`, agent_running=null ✓
- `GET /queue` → блок `serial_gate` **присутствует** (ORCH-088): `enabled=true`, репо
`orchestrator` — active_task ORCH-095 `testing`, `frozen=false`, waiting пуст; блок
`auto_labels` **присутствует** (ORCH-089). Регресса смока нет. ✓
## Результаты
### Полный регресс
`cd <worktree> && pytest tests/ -v --tb=short`**1437 passed, 1 warning in 46.89s**.
Единственное предупреждение — PydanticDeprecatedSince20 (унаследованное, не относится к задаче).
### Профильная сюита (ORCH-095)
`pytest tests/test_tracker_html_escape.py -v`**24 passed** (новый файл, TC-01…TC-11).
### Регресс существующих тестов трекера (TC-12)
`pytest tests/test_telegram_tracker.py tests/test_tracker_issue_link.py
tests/test_tracker_status_line.py tests/test_notifications_orphans.py
tests/test_notify_issue_links.py -q`**91 passed**.
### Сопоставление с тест-планом (04-test-plan.yaml)
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-01 | `_fmt_minutes(<60с)` → HTML-безопасно, без сырого `<1м` | `test_tc01_sub_minute_duration_escaped_at_boundary` | PASS |
| TC-02 | `_fmt_minutes` граничные входы (0/None/нечисло/60/большое/-5/59/61) — never-raise + безопасно | `test_tc02_fmt_minutes_never_raise_and_safe[*]` (9 кейсов) | PASS |
| TC-03 | `render_task_tracker` со стадией < 1 мин — нет неэкранированного `<` из длительности | `test_tc03_render_sub_minute_stage_is_safe` | PASS |
| TC-04 | Заголовок со спецсимволами `< > &` — только экранированно, без двойного экранирования | `test_tc04_title_special_chars_escaped_no_double` | PASS |
| TC-05 | Статус-лейбл / имя модели / эффорт экранированы (defence-in-depth) | `test_tc05_status_label_escaped`, `test_tc05_model_escaped`, `test_tc05_effort_escaped` | PASS |
| TC-06 | Токены/стоимость (`$`, числа) HTML-безопасны | `test_tc06_token_cost_metrics_safe` | PASS |
| TC-07 | Регресс намеренной разметки: `<a href>` номер задачи остаётся кликабельным, не задвоен | `test_tc07_issue_number_stays_clickable` | PASS |
| TC-08 | Регресс `_done_link`: строка `🔗 PR #n · 📦 Внедрено` валидна, не экранирована | `test_tc08_done_link_markup_preserved` | PASS |
| TC-09 | `update_task_tracker` (edit) — payload text не содержит сырого `<1м`-триггера | `test_tc09_edit_payload_is_parse_safe` | PASS |
| TC-10 | Возобновление застрявшей карточки + анти-дубль ORCH-087 на транзиентном фейле | `test_tc10_valid_render_edits_in_place_no_new_card`, `test_tc10_transient_fail_does_not_duplicate` | PASS |
| TC-11 | never-raise на битых входах (нет задачи / None-заголовок / битые timestamps / `_esc`) | `test_tc11_never_raise_missing_task`, `test_tc11_never_raise_none_title_and_bad_timestamps`, `test_tc11_esc_never_raises` | PASS |
| TC-12 | Полный регресс существующих тестов трекера остаётся зелёным | suite (91 passed) + полный регресс (1437 passed) | PASS |
**Все 12 TC выполнены и сопоставлены.**
### Сопоставление с критериями приёмки (03-acceptance-criteria.md)
| AC | Содержание | Покрытие | Результат |
|----|------------|----------|-----------|
| AC-1 | Стадия < 1 мин не ломает парсер Telegram (`&lt;1м`) | TC-01, TC-03, TC-09 | PASS |
| AC-2 | Все динамические поля HTML-безопасны, без двойного экранирования | TC-02, TC-04, TC-05, TC-06 | PASS |
| AC-3 | Регресс намеренной разметки (`<a href>` номер, `_done_link`, форматирование) | TC-07, TC-08, TC-12 | PASS |
| AC-4 | Застрявшая карточка возобновляет обновления; анти-дубль ORCH-087 цел | TC-10 | PASS |
| AC-5 | never-raise, зелёный регресс, CHANGELOG, машина стадий/гейты/схема БД не тронуты | TC-11, TC-12, полный регресс 1437 passed | PASS |
## Вывод pytest
```
======================= 1437 passed, 1 warning in 46.89s =======================
```
Профильная сюита:
```
======================== 24 passed, 1 warning in 1.31s =========================
```
Регресс трекера (TC-12):
```
91 passed, 1 warning in 4.32s
```
## Итог
PASS — полный регресс зелёный (1437 passed), профильная сюита ORCH-095 зелёная (24 passed),
каждый TC из тест-плана выполнен и сопоставлен с критериями приёмки, smoke API read-only
(`/health`, `/status`, `/queue` с блоками `serial_gate` + `auto_labels`) без регресса.
Обоснованных FAIL/смок-сбоев нет → `result: PASS` → задача переходит на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-095
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,39 @@
---
staging_status: SUCCESS
work_item: ORCH-095
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-10
model_used: claude-opus-4-8
timestamp: 2026-06-09T21:15:53Z
base_url: http://localhost:8501
---
# Staging Gate Log
> Машинный вердикт читается ТОЛЬКО из `staging_status:` во frontmatter. Реален для self-hosting
> (`orchestrator`). `SUCCESS` → дальше; `FAILED` → откат.
Staging test suite completed against the live `orchestrator-staging` stand (8501). Run canonically
**inside the container** via the Docker Engine API over `/var/run/docker.sock` (the `docker` CLI
binary is unavailable in the agent sandbox; the exec was driven through the socket — equivalent to
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`). **Exit code 0 → `staging_status: SUCCESS`.**
All REAL pipeline checks passed. The two non-passing checks are the known sandbox-infra checks
(C9a/C9b), waived per ORCH-061 (SANDBOX bot accounts are not members of the sandbox Plane project —
this is not a pipeline regression). Verdict line from the script:
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
## Results — 8/10 checks PASS (exit 0)
- **Block A (SMOKE)**: A1 `/health` 200 ok · A2 `/queue` 200 with counts/max_concurrency/resilience · A3 `ORCH_STAGING=true`. All PASS.
- **Block B (ACCESS)**: B4 Plane sandbox accessible (sandbox=YES) · B5 Gitea `orchestrator-sandbox` accessible push=true · B6 Registry isolation (sandbox=YES, prod-ET=NO, prod-ORCH=NO). All PASS.
- **Block C (E2E, stub)**: C7 Create issue in Plane SANDBOX PASS · C8 Trigger pipeline via `/webhook/plane` PASS · C9a/C9b FAIL → **waived** (sandbox-infra). Cleanup: Plane issue deleted (HTTP 204).
REAL failed: **none**.
SANDBOX_INFRA waived: C9a (branch in orchestrator-sandbox), C9b (analyst job enqueued).

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-095
window_s: 900
checks_total: 30
checks_failed: 0
---
# Post-deploy log — ORCH-021 post-deploy monitor
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.

View File

@@ -679,17 +679,47 @@ class AgentLauncher:
if timeout is None: if timeout is None:
timeout = self._resolve_timeout(agent) timeout = self._resolve_timeout(agent)
time.sleep(timeout) time.sleep(timeout)
# ORCH-090: the SIGTERM->grace->SIGKILL cascade is now a reusable helper
# (stop_process) shared with the STOP-cancellation path. The timeout
# watchdog just sleeps the timeout, then drives the cascade.
logger.warning(
f"Agent run_id={run_id} exceeded {timeout}s timeout (pid={pid})"
)
self.stop_process(pid, run_id, reason=f"timeout>{timeout}s")
def stop_process(self, pid: int, run_id: int | None, *, reason: str = "stop") -> bool:
"""ORCH-7 / ORCH-090 (ADR-001 D2): graceful SIGTERM->grace->SIGKILL cascade.
Extracted from ``_watchdog`` so the STOP-cancellation path
(``stage_engine.cancel_task``) stops an active agent through the SAME
graceful cascade instead of a new "dirty" kill (AC-1). Send SIGTERM, give
the process up to ``settings.agent_kill_grace_seconds`` to flush and exit,
SIGKILL only if it is still alive after the grace; stamp ``agent_runs``
exit_code=-9 via ``_record_kill`` whenever a kill actually happened.
never-raise; ``ProcessLookupError`` is tolerated at every step (the process
may already be gone). Returns True iff a SIGTERM was delivered to a live
process; False when the process was already gone (no record — the monitor's
``proc.wait()`` owns that exit).
"""
if pid is None:
return False
# Phase 1: SIGTERM (graceful). If the process is already gone, we're done. # Phase 1: SIGTERM (graceful). If the process is already gone, we're done.
try: try:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
logger.warning( logger.warning(
f"Agent run_id={run_id} exceeded {timeout}s timeout: sent SIGTERM " f"stop_process ({reason}): sent SIGTERM to pid={pid} "
f"(pid={pid}), grace={settings.agent_kill_grace_seconds}s" f"(run_id={run_id}), grace={settings.agent_kill_grace_seconds}s"
) )
except ProcessLookupError: except ProcessLookupError:
logger.info(f"Agent run_id={run_id} already exited before SIGTERM") logger.info(
return # nothing to record: the monitor's proc.wait() owns the exit f"stop_process ({reason}): pid={pid} already exited "
f"(run_id={run_id}); nothing to record"
)
return False
except Exception as e: # noqa: BLE001 - never-raise
logger.warning(f"stop_process SIGTERM error pid={pid}: {e}")
return False
# Phase 2: poll for graceful exit within the grace window. # Phase 2: poll for graceful exit within the grace window.
grace = settings.agent_kill_grace_seconds grace = settings.agent_kill_grace_seconds
@@ -702,21 +732,27 @@ class AgentLauncher:
os.kill(pid, 0) # signal 0 = liveness probe, does not kill os.kill(pid, 0) # signal 0 = liveness probe, does not kill
except ProcessLookupError: except ProcessLookupError:
logger.info( logger.info(
f"Agent run_id={run_id} exited gracefully after SIGTERM " f"stop_process ({reason}): pid={pid} exited gracefully after "
f"({waited:.1f}s); no SIGKILL needed" f"SIGTERM ({waited:.1f}s); no SIGKILL needed"
) )
self._record_kill(run_id) self._record_kill(run_id)
return return True
except Exception: # noqa: BLE001 - probe error -> escalate to SIGKILL
break
# Phase 3: still alive -> hard SIGKILL. # Phase 3: still alive -> hard SIGKILL.
try: try:
os.kill(pid, signal.SIGKILL) os.kill(pid, signal.SIGKILL)
logger.warning( logger.warning(
f"Agent run_id={run_id} did not exit within {grace}s grace: sent SIGKILL" f"stop_process ({reason}): pid={pid} did not exit within {grace}s "
f"grace: sent SIGKILL"
) )
except ProcessLookupError: except ProcessLookupError:
logger.info(f"Agent run_id={run_id} exited just before SIGKILL") logger.info(f"stop_process ({reason}): pid={pid} exited just before SIGKILL")
except Exception as e: # noqa: BLE001 - never-raise
logger.warning(f"stop_process SIGKILL error pid={pid}: {e}")
self._record_kill(run_id) self._record_kill(run_id)
return True
@staticmethod @staticmethod
def _record_kill(run_id: int): def _record_kill(run_id: int):

187
src/cancel.py Normal file
View File

@@ -0,0 +1,187 @@
"""ORCH-090 (ADR-001 D9 / adr-0026): STOP-cancellation leaf — pure decision logic.
Leaf module mirroring ``src/serial_gate.py`` / ``src/labels.py``: pure,
unit-testable, never-raise functions over config + the existing DB / deploy-state.
Module-level imports are limited to ``config`` (and ``re``); the critical-window
probe lazily imports ``self_deploy`` / ``merge_gate`` / ``db`` so a cycle can never
form and an import failure degrades safely.
What it answers:
* ``applies(repo)`` — is STOP-cancellation REAL for this repo?
* ``in_critical_window(task)``— is the task inside an irreversible merge/deploy
step where cancellation must be DEFERRED (ADR-001 D7) instead of applied now?
* ``snapshot()`` — read-only summary for ``GET /queue`` (AC-10).
The ORCHESTRATION of a cancellation (SIGTERM, cancel-jobs, worktree/branch
cleanup, key tombstone, notifications) lives in ``stage_engine.cancel_task`` — this
leaf only decides, it never mutates.
never-raise contract (self-hosting safety): every public function degrades
conservatively. ``applies`` -> False on error (gate inert, the kill-switch-off
default). ``in_critical_window`` -> True on doubt (fail-CLOSED: when we cannot
confirm we are OUTSIDE a critical window, DEFER cancellation rather than risk
tearing a half-merge / detached prod deploy, NFR-3 / TR-3).
"""
from __future__ import annotations
import logging
import re
from .config import settings
logger = logging.getLogger("orchestrator.cancel")
# Repo tokens in the CSV scope must match this (mirrors serial_gate._REPO_TOKEN).
_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$")
def _scope_repos() -> set[str]:
"""Sanitised set of in-scope repo tokens from ``stop_status_repos`` (CSV).
Empty/blank CSV -> empty set, meaning "apply to ALL repos" (D9). Invalid tokens
(regex miss) are dropped. Never raises.
"""
try:
raw = (settings.stop_status_repos or "").strip()
except Exception: # noqa: BLE001
return set()
if not raw:
return set()
out: set[str] = set()
for tok in raw.split(","):
t = tok.strip()
if t and _REPO_TOKEN.match(t):
out.add(t)
elif t:
logger.warning("cancel: dropping invalid repo token %r from CSV", t)
return out
def applies(repo: str) -> bool:
"""Whether STOP-cancellation is REAL for this repo (D9 / AC-8).
* ``stop_status_enabled=False`` -> always False (kill-switch; STOP handling and
the relaunch-hole gate are 1:1 as before ORCH-090).
* ``stop_status_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real for ALL repos (cancellation is meaningful for enduro too).
Never raises -> False on error (degrade to "inert", matching kill-switch off).
"""
try:
if not getattr(settings, "stop_status_enabled", False):
return False
scope = _scope_repos()
if scope:
return (repo or "").strip() in scope
return True
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("cancel.applies error for %s: %s", repo, e)
return False
def _task_has_running_actor(task_id) -> bool:
"""True iff the task currently has a RUNNING job — an active merge/deploy actor.
Distinguishes a genuinely in-flight merge/deploy (a running deployer / deploy
finalizer job actually executing the irreversible step) from a task merely
PARKED on ``deploy`` awaiting the human ``Confirm Deploy`` (the merge-lease is
held across that wait, ORCH-036/043, but nothing is executing and nothing has
been merged/deployed). Lazily imports ``db``; raises on a db error so the caller
fails CLOSED (treat as critical) rather than silently mis-classifying on doubt.
"""
if not task_id:
return False
from . import db
for job in db.get_active_jobs_for_task(task_id):
if job.get("status") == "running":
return True
return False
def in_critical_window(task: dict) -> bool:
"""Is the task inside an irreversible merge/deploy step (ADR-001 D7 / AC-7)?
A STOP that lands here must NOT tear the step apart (half-merge / detached prod
deploy / dead prod container, NFR-3). Markers (existing, no new state):
* self-deploy Phase B initiated — the ``INITIATED`` sentinel in
``<repos_dir>/.deploy-state-<repo>/<wi>/`` (ORCH-036) — the detached prod
deploy + the deterministic ``merge_pr`` (``_handle_merge_verify``, run later
under the SAME marker) are both covered here;
* the task HOLDS the per-repo merge-lease ``<repos_dir>/.merge-lease-<repo>.json``
(ORCH-043), holder branch == task branch, **AND** a merge/deploy actor is
actually RUNNING.
The merge-lease branch is gated on a running actor on purpose (ORCH-090 review
P1 fix). For the self-hosting repo the lease is HELD from the merge-gate PASS
(``deploy-staging -> deploy`` edge) right through to ``deploy -> done`` — including
the whole time the task sits PARKED on ``deploy`` awaiting a human ``Confirm
Deploy`` (Phase A). That wait is FULLY REVERSIBLE: nothing is merged or deployed
(the irreversible ``merge_pr`` only runs later in ``_handle_merge_verify``, always
under an ``INITIATED`` marker already caught above). Classifying that idle parking
as "critical" used to DEFER the cancel to a deploy finalizer that the operator —
having pressed STOP precisely to NOT confirm — never triggers, so the cancel was
never applied and the task wedged while still holding the lease (blocking the
repo's serial-gate / merges). Now idle parking (lease held, no running actor) is
NOT critical: the full reset runs immediately and itself releases the lease.
fail-CLOSED (TR-3): any error/uncertainty -> True (DEFER cancellation). Outside
the window -> False (apply the full reset immediately).
"""
if not task:
return False
repo = task.get("repo")
work_item_id = task.get("work_item_id")
branch = task.get("branch")
try:
from . import self_deploy
if self_deploy.has_marker(repo, work_item_id, self_deploy.INITIATED):
return True
except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt
logger.warning("cancel.in_critical_window self_deploy probe error: %s", e)
return True
try:
from . import merge_gate
holder = merge_gate.current_lease_holder(repo)
if holder and branch and holder == branch:
# Lease held. Critical ONLY if an actor is actively merging/deploying;
# an idle task parked on `deploy` awaiting Confirm Deploy is reversible.
if _task_has_running_actor(task.get("id")):
return True
logger.info(
"cancel.in_critical_window: task %s holds the merge-lease but no "
"actor is running (idle deploy parking, awaiting Confirm Deploy) -> "
"NOT critical; full reset will release the lease", task.get("id"),
)
return False
except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt
logger.warning("cancel.in_critical_window merge-lease probe error: %s", e)
return True
return False
def snapshot() -> dict:
"""Read-only STOP-cancellation summary for GET /queue (AC-10).
Additive block; existing /queue keys are untouched. never-raise -> a minimal
dict with the flags on error.
"""
try:
enabled = bool(getattr(settings, "stop_status_enabled", False))
except Exception: # noqa: BLE001
enabled = False
try:
repos_cfg = getattr(settings, "stop_status_repos", "") or ""
except Exception: # noqa: BLE001
repos_cfg = ""
try:
from . import db
stats = db.cancelled_tasks_snapshot(10)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("cancel.snapshot error: %s", e)
stats = {"count": 0, "pending": 0, "recent": []}
return {
"enabled": enabled,
"repos": repos_cfg,
"cancelled_count": stats.get("count", 0),
"deferred_pending": stats.get("pending", 0),
"recent": stats.get("recent", []),
}

View File

@@ -549,6 +549,31 @@ class Settings(BaseSettings):
merge_pr_timeout_s: int = 60 merge_pr_timeout_s: int = 60
merge_verify_timeout_s: int = 60 merge_verify_timeout_s: int = 60
# ORCH-093: deterministic merge-actor retry of TRANSIENT Gitea merge errors.
# The incident ORCH-063 had a green self-deploy + an open, mergeable PR, yet
# POST /pulls/{n}/merge returned HTTP 405 ("Please try again later") because
# Gitea was still recomputing `mergeable` right after the push — the one-shot
# merge_pr returned False, the ORCH-071/081 backstop HELD the task on `deploy`,
# and a human had to re-merge by hand. merge_pr now wraps ONLY the mutating
# POST in a bounded exponential-backoff retry-loop on TRANSIENT outcomes
# (405/408/5xx/network-timeout, and 409|422 while the PR is still mergeable);
# TERMINAL outcomes (403/404/real conflict) -> fast honest False (the HOLD
# protection is unchanged). Mirrors the ci_poll_* idiom of check_ci_green.
# merge_retry_enabled -> kill-switch; False -> exactly one POST
# (byte-for-byte the prior one-shot behaviour,
# env ORCH_MERGE_RETRY_ENABLED).
# merge_retry_max_attempts -> max POST attempts on a transient outcome
# (env ORCH_MERGE_RETRY_MAX_ATTEMPTS).
# merge_retry_backoff_base_s -> exponential backoff base seconds
# (env ORCH_MERGE_RETRY_BACKOFF_BASE_S).
# merge_retry_backoff_max_s -> per-sleep backoff ceiling seconds; total sleep
# is bounded by (N-1) * max so the monitor-thread
# is never wedged (env ORCH_MERGE_RETRY_BACKOFF_MAX_S).
merge_retry_enabled: bool = True
merge_retry_max_attempts: int = 3
merge_retry_backoff_base_s: int = 2
merge_retry_backoff_max_s: int = 5
# ORCH-026: intra-repo merge serialisation (Level A) + declarative task # ORCH-026: intra-repo merge serialisation (Level A) + declarative task
# dependencies (Level B). Level A reuses the ORCH-043/065 merge-lease window # dependencies (Level B). Level A reuses the ORCH-043/065 merge-lease window
# (no new mechanism) — the merge-lease already serialises "merge -> main-updated" # (no new mechanism) — the merge-lease already serialises "merge -> main-updated"
@@ -605,6 +630,51 @@ class Settings(BaseSettings):
serial_gate_repos: str = "" serial_gate_repos: str = ""
serial_gate_freeze_enabled: bool = True serial_gate_freeze_enabled: bool = True
# ORCH-090: STOP-status task cancellation (stop active agent + full progress
# reset) and the relaunch-hole close. A new logical Plane key `stop` (fail-closed,
# absent from _DEFAULT_STATES) routes to a cancel handler that drives the task to
# the new system-terminal state `cancelled` (stage + durable). Additive,
# never-raise, restart-safe; STAGE_TRANSITIONS / QG_CHECKS / check_* / existing
# status semantics are NOT touched. See
# docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md and the cross-cutting
# docs/architecture/adr/adr-0026-stop-cancel-task.md.
# stop_status_enabled -> kill-switch (env ORCH_STOP_STATUS_ENABLED). False ->
# STOP handling AND the relaunch-hole gate are inert
# (behaviour strictly as before ORCH-090 — zero
# regression, AC-8).
# stop_status_repos -> CSV scope (env ORCH_STOP_STATUS_REPOS). Empty -> applies
# to ALL repos (cancellation is meaningful for enduro too);
# non-empty -> only the listed repos. Tokens are sanitised
# (^[A-Za-z0-9._-]+$) by the cancel leaf.
stop_status_enabled: bool = True
stop_status_repos: str = ""
# ORCH-094: terminal-window-aware guard for deploy-phase Plane status setters.
# A task with DB stage='done' (and 0 active jobs) was flapping in Plane between
# `Awaiting Deploy` and `Monitoring after Deploy` instead of holding `Done`,
# because the three deploy-phase setters (set_issue_awaiting_deploy /
# set_issue_deploying / set_issue_monitoring) are terminal-blind: any stale /
# duplicate / unknown caller under the bot token re-stamps an intermediate
# deploy status over the terminal Done. ORCH-094 puts a single low choke-point
# guard on the entry of those three setters (leaf src/deploy_status_guard.py):
# for a task whose DB stage is terminal it converges to Done idempotently
# (CONVERGE_DONE), EXCEPT the legitimate post-deploy `Monitoring` while the
# window is still active (ARMED & not DONE). Additive, never-raise; reads the
# existing tasks.stage (no migration); STAGE_TRANSITIONS / QG_CHECKS /
# machine-verdict keys are NOT touched. See
# docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md
# and the cross-cutting docs/architecture/adr/adr-0028-…md.
# deploy_status_guard_enabled -> kill-switch (env ORCH_DEPLOY_STATUS_GUARD_ENABLED).
# False -> the setters are terminal-blind, behaviour
# strictly 1:1 as before ORCH-094 (zero regression).
# deploy_status_guard_repos -> CSV scope (env ORCH_DEPLOY_STATUS_GUARD_REPOS).
# Empty -> applies ONLY to the self-hosting repo
# (orchestrator), where deploy-phase statuses are set
# at all; non-empty -> only the listed repos. Tokens
# are sanitised (^[A-Za-z0-9._-]+$) by the guard leaf.
deploy_status_guard_enabled: bool = True
deploy_status_guard_repos: str = ""
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify # ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify
# under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a # under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a
# secondary deterministic (no-LLM) guard checks that a declarative set of markers # secondary deterministic (no-LLM) guard checks that a declarative set of markers

233
src/db.py
View File

@@ -59,7 +59,7 @@ def init_db():
repo TEXT NOT NULL, repo TEXT NOT NULL,
task_id INTEGER, -- FK tasks.id (nullable) task_id INTEGER, -- FK tasks.id (nullable)
task_content TEXT, -- written to the agent task_file task_content TEXT, -- written to the agent task_file
status TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|failed status TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|failed|cancelled (ORCH-090: cancelled is a terminal outcome, never requeued)
attempts INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 2, max_attempts INTEGER NOT NULL DEFAULT 2,
run_id INTEGER, -- agent_runs.id once started run_id INTEGER, -- agent_runs.id once started
@@ -129,6 +129,17 @@ def init_db():
# tracker can show "твоё время" without recomputing from activity history. # tracker can show "твоё время" without recomputing from activity history.
_ensure_column(conn, "tasks", "brd_review_started_at", "TEXT") _ensure_column(conn, "tasks", "brd_review_started_at", "TEXT")
_ensure_column(conn, "tasks", "brd_review_ended_at", "TEXT") _ensure_column(conn, "tasks", "brd_review_ended_at", "TEXT")
# ORCH-090 (08-data-requirements.md): STOP-cancellation durable markers. Both are
# additive, idempotent (_ensure_column is a no-op once present) -> safe on the live
# shared prod DB (enduro untouched). The durable terminal itself is tasks.stage=
# 'cancelled' (already understood by the reconciler terminal-skip); these columns
# are audit/observability + the deferred-cancel signal.
# cancelled_at -> timestamp the task was cancelled (NULL otherwise).
# cancel_requested_at -> STOP arrived inside a critical merge/deploy window
# (ADR-001 D7): cancellation is DEFERRED until the
# irreversible step finishes honestly, then applied.
_ensure_column(conn, "tasks", "cancelled_at", "TEXT")
_ensure_column(conn, "tasks", "cancel_requested_at", "TEXT")
# ORCH-026 (Level B): declarative task dependencies. job_deps stores the # ORCH-026 (Level B): declarative task dependencies. job_deps stores the
# directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The # directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The
# scheduler gate in claim_next_job keeps B queued until every A reaches # scheduler gate in claim_next_job keeps B queued until every A reaches
@@ -212,6 +223,28 @@ def get_task_by_plane_id(plane_id: str) -> dict | None:
return None return None
def get_task_by_work_item_id(work_item_id: str) -> dict | None:
"""ORCH-094: read-only lookup of the live task row by human-readable
``work_item_id`` (e.g. ``"ORCH-061"``).
``get_task_by_plane_id`` matches the Plane UUIDs (``plane_id`` /
``plane_issue_id``), not the human-readable ``work_item_id`` the deploy-phase
setters receive — hence this thin accessor. A live row matches exactly; the
ORCH-090 cancel tombstones carry a ``#cancelled-<id>`` suffix on
``work_item_id`` so they never collide with a clean id. No schema change.
"""
if not work_item_id:
return None
conn = get_db()
try:
row = conn.execute(
"SELECT * FROM tasks WHERE work_item_id = ?", (work_item_id,)
).fetchone()
finally:
conn.close()
return dict(row) if row else None
def get_task_by_repo_branch(repo: str, branch: str) -> dict | None: def get_task_by_repo_branch(repo: str, branch: str) -> dict | None:
"""Find task by repo and branch name.""" """Find task by repo and branch name."""
conn = get_db() conn = get_db()
@@ -231,6 +264,13 @@ def get_active_tasks_for_reconcile() -> list[dict]:
``age_s`` = seconds since ``tasks.updated_at`` (computed in SQL against UTC ``age_s`` = seconds since ``tasks.updated_at`` (computed in SQL against UTC
'now', matching how ``update_task_stage`` stamps ``updated_at``). The 'now', matching how ``update_task_stage`` stamps ``updated_at``). The
reconciler applies the per-stage grace and active-job guard on top. reconciler applies the per-stage grace and active-job guard on top.
ORCH-090 (adr-0026): a ``cancelled`` task is DELIBERATELY still returned here
and skipped by the reconciler's own terminal-skip (``stage in
('done','cancelled')``, ORCH-086 D2) — narrowing the query to exclude
``cancelled`` would lose the observability skip-counter increment that ORCH-086
relies on. The terminal set is harmonised in the *scheduler* predicates
(serial_gate / task_deps), not here.
""" """
conn = get_db() conn = get_db()
try: try:
@@ -605,7 +645,9 @@ def claim_next_job() -> dict | None:
dep_gate = ( dep_gate = (
"AND NOT EXISTS (" "AND NOT EXISTS ("
" SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id " " SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
" WHERE d.task_id = jobs.task_id AND t.stage != 'done'" # ORCH-090 (adr-0026): a cancelled predecessor is TERMINAL -> the
# dependent must NOT wait on it forever. Terminal set = {done,cancelled}.
" WHERE d.task_id = jobs.task_id AND t.stage NOT IN ('done','cancelled')"
") " ") "
) )
# ORCH-088 (FR-1, ADR-001 D1): per-repo serial gate. An analyst-job of a NEW # ORCH-088 (FR-1, ADR-001 D1): per-repo serial gate. An analyst-job of a NEW
@@ -683,11 +725,11 @@ def mark_job(
run_id: int | None = None, run_id: int | None = None,
error: str | None = None, error: str | None = None,
): ):
"""Update a job's status (queued|running|done|failed). """Update a job's status (queued|running|done|failed|cancelled).
- run_id (optional): link to the agent_runs row that executed this job. - run_id (optional): link to the agent_runs row that executed this job.
- error (optional): last error message (for failed/retry). - error (optional): last error message (for failed/retry).
- 'done'/'failed' also stamp finished_at. - 'done'/'failed'/'cancelled' (ORCH-090) also stamp finished_at.
- 'queued' (requeue for retry) clears started_at/finished_at so the next - 'queued' (requeue for retry) clears started_at/finished_at so the next
claim treats it as fresh. claim treats it as fresh.
""" """
@@ -700,7 +742,7 @@ def mark_job(
if error is not None: if error is not None:
sets.append("error = ?") sets.append("error = ?")
params.append(error) params.append(error)
if status in ("done", "failed"): if status in ("done", "failed", "cancelled"):
sets.append("finished_at = datetime('now')") sets.append("finished_at = datetime('now')")
elif status == "queued": elif status == "queued":
sets.append("started_at = NULL") sets.append("started_at = NULL")
@@ -728,6 +770,181 @@ def has_active_job_for_task(task_id: int) -> bool:
return row is not None return row is not None
# ---------------------------------------------------------------------------
# ORCH-090: STOP-cancellation helpers (task + jobs terminal state)
# ---------------------------------------------------------------------------
def get_task(task_id: int) -> dict | None:
"""Fetch a single task row by id (None when absent)."""
conn = get_db()
try:
row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
finally:
conn.close()
return dict(row) if row else None
def get_active_jobs_for_task(task_id: int) -> list[dict]:
"""ORCH-090: queued/running jobs of a task (for STOP — stop agent + cancel).
Returns the full job rows (incl. ``pid`` / ``run_id`` / ``status``) so the
cancel orchestrator can SIGTERM the running agent by ``jobs.pid`` and then flip
every job to the terminal ``cancelled`` outcome.
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT * FROM jobs WHERE task_id = ? AND status IN ('queued','running') "
"ORDER BY id",
(task_id,),
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def cancel_jobs_for_task(task_id: int, only_queued: bool = False) -> int:
"""ORCH-090 (ADR-001 D3): flip a task's jobs to the terminal ``cancelled`` outcome.
Guarded UPDATE over ``status IN ('queued','running')`` (or only ``'queued'`` when
``only_queued`` — the deferred-cancel path inside a critical merge/deploy window,
D7, which must NOT cancel the still-running deploy/merge actor). ``cancelled`` is
never requeued: ``claim_next_job`` only selects ``status='queued'`` and the reaper
/ worker check the task's terminal stage before any requeue. Returns the number of
jobs cancelled. never-raise -> 0 on error.
"""
statuses = "('queued')" if only_queued else "('queued','running')"
try:
conn = get_db()
try:
cur = conn.execute(
f"UPDATE jobs SET status='cancelled', finished_at=datetime('now') "
f"WHERE task_id = ? AND status IN {statuses}",
(task_id,),
)
conn.commit()
return cur.rowcount or 0
finally:
conn.close()
except Exception:
return 0
def mark_task_cancelled(task_id: int) -> bool:
"""ORCH-090 (ADR-001 D4): durable terminal + natural-key tombstone for a task.
Atomically (single UPDATE):
* ``stage='cancelled'`` (durable terminal, understood by the reconciler skip);
* ``cancelled_at=now``, ``cancel_requested_at=NULL`` (clear any deferred flag);
* TOMBSTONE the natural keys so a later "To Analyse" re-creates the task FROM
SCRATCH: ``plane_id`` / ``work_item_id`` / ``plane_issue_id`` get a
deterministic ``#cancelled-<id>`` suffix -> ``get_task_by_plane_id`` returns
None and the anti-dup / uniqueness guards no longer collide. The row is NOT
deleted (durable audit).
ADR-001 D4 refinement (ORCH-090): the ADR proposed keeping ``plane_issue_id``
untouched for audit, but ``get_task_by_plane_id`` / ``create_task_atomic`` match
on ``plane_id OR plane_issue_id`` — leaving ``plane_issue_id`` matchable would
keep the cancelled row "findable" and BLOCK the clean-slate re-create (BR-3 /
TR-4). We therefore suffix it too; the ``#cancelled-<id>`` tag is deterministic
and parseable, so the original Plane issue UUID (== the original ``plane_id`` in
every create path) is still fully recoverable for audit.
Idempotent-safe: the suffix is only appended when not already present (a repeat
STOP on an already-cancelled row does not double-suffix). Returns True iff the
row was updated. never-raise -> False on error.
"""
try:
conn = get_db()
try:
row = conn.execute(
"SELECT plane_id, work_item_id, plane_issue_id FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
if not row:
return False
suffix = f"#cancelled-{task_id}"
def _tomb(v):
v = v or ""
return v if suffix in v else f"{v}{suffix}"
plane_id = _tomb(row["plane_id"])
work_item_id = _tomb(row["work_item_id"])
plane_issue_id = _tomb(row["plane_issue_id"])
conn.execute(
"UPDATE tasks SET stage='cancelled', cancelled_at=datetime('now'), "
"cancel_requested_at=NULL, plane_id=?, work_item_id=?, plane_issue_id=?, "
"updated_at=datetime('now') WHERE id = ?",
(plane_id, work_item_id, plane_issue_id, task_id),
)
conn.commit()
return True
finally:
conn.close()
except Exception:
return False
def set_task_cancel_requested(task_id: int) -> bool:
"""ORCH-090 (ADR-001 D7): mark a deferred cancellation (STOP in critical window).
Idempotent: only stamps ``cancel_requested_at`` the first time. Returns the
**first-stamp fact** — ``True`` iff THIS call actually stamped the column (a
repeated STOP while still deferred updates 0 rows -> ``False``), so the caller can
suppress duplicate notifications (AC-6). The deterministic deploy/merge finalizer
reads the column once the irreversible step completes and then applies the full
cancellation. never-raise -> False on error.
"""
try:
conn = get_db()
try:
cur = conn.execute(
"UPDATE tasks SET cancel_requested_at=datetime('now') "
"WHERE id = ? AND cancel_requested_at IS NULL",
(task_id,),
)
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
except Exception:
return False
def cancelled_tasks_snapshot(limit: int = 10) -> dict:
"""ORCH-090 (AC-10): read-only cancellation summary for GET /queue.
Returns ``{count, pending, recent}`` where ``count`` is the number of cancelled
tasks, ``pending`` the number with a deferred (not-yet-applied) cancellation, and
``recent`` the last ``limit`` cancelled tasks. never-raise -> minimal dict.
"""
try:
conn = get_db()
try:
count = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE stage='cancelled'"
).fetchone()[0]
pending = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE cancel_requested_at IS NOT NULL "
"AND stage != 'cancelled'"
).fetchone()[0]
recent = [
{"work_item_id": r["work_item_id"], "repo": r["repo"],
"cancelled_at": r["cancelled_at"]}
for r in conn.execute(
"SELECT work_item_id, repo, cancelled_at FROM tasks "
"WHERE stage='cancelled' ORDER BY cancelled_at DESC LIMIT ?",
(limit,),
).fetchall()
]
finally:
conn.close()
return {"count": int(count), "pending": int(pending), "recent": recent}
except Exception:
return {"count": 0, "pending": 0, "recent": []}
def count_running_jobs() -> int: def count_running_jobs() -> int:
"""Number of jobs currently in 'running' status (for max_concurrency).""" """Number of jobs currently in 'running' status (for max_concurrency)."""
conn = get_db() conn = get_db()
@@ -815,7 +1032,7 @@ def reap_running_job(
if error is not None: if error is not None:
sets.append("error = ?") sets.append("error = ?")
params.append(error) params.append(error)
if status in ("done", "failed"): if status in ("done", "failed", "cancelled"): # ORCH-090: cancelled is terminal
sets.append("finished_at = datetime('now')") sets.append("finished_at = datetime('now')")
elif status == "queued": elif status == "queued":
sets.append("started_at = NULL") sets.append("started_at = NULL")
@@ -948,7 +1165,9 @@ def get_unfinished_dependencies(task_id: int) -> list[dict]:
rows = conn.execute( rows = conn.execute(
"SELECT t.id AS id, t.work_item_id AS work_item_id, t.stage AS stage " "SELECT t.id AS id, t.work_item_id AS work_item_id, t.stage AS stage "
"FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id " "FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
"WHERE d.task_id = ? AND t.stage != 'done'", # ORCH-090 (adr-0026): {done,cancelled} are both terminal -> a
# cancelled predecessor no longer blocks the dependent.
"WHERE d.task_id = ? AND t.stage NOT IN ('done','cancelled')",
(task_id,), (task_id,),
).fetchall() ).fetchall()
finally: finally:

191
src/deploy_status_guard.py Normal file
View File

@@ -0,0 +1,191 @@
"""ORCH-094: terminal-window-aware guard for deploy-phase Plane status setters.
Leaf module — pure, never-raise, config-gated logic over the existing ``tasks``
table and the restart-safe post-deploy sentinels. Mirrors the leaf pattern of
``src/serial_gate.py`` / ``src/labels.py`` / ``src/cancel.py``: it imports only
``config`` (and lazily ``db`` / ``post_deploy`` / ``qg.checks``), never
``plane_sync`` / ``stage_engine`` — the setters that need a verdict call
:func:`decide`, they do not live here.
The bug (verified live on ORCH-061, task 47, done since 07.06): a task with DB
``stage='done'`` and no active job flaps in Plane between ``Awaiting Deploy`` and
``Monitoring after Deploy`` instead of holding ``Done``. The three deploy-phase
setters (``set_issue_awaiting_deploy`` / ``set_issue_deploying`` /
``set_issue_monitoring``) are **terminal-blind**: any stale / duplicate / unknown
caller under the bot token re-stamps an intermediate deploy status over the
terminal Done, and the pendulum never settles.
The fix is a single low choke-point on the entry of those three setters. For a
task whose DB stage is terminal the verdict converges to ``Done`` idempotently,
EXCEPT the one legitimate case: the post-deploy ``Monitoring`` status while the
observation window is still active (``post_deploy.window_active`` — ARMED & not
DONE). The deploy ``Awaiting``/``Deploying`` statuses are ALWAYS spurious for a
``done`` task (Phase A/B happen strictly BEFORE ``deploy -> done``).
Key invariant (ADR-001 D2): a deploy-phase status is legitimate iff the task is
non-terminal OR (``done`` AND the post-deploy window is active); otherwise the
verdict is idempotent convergence to ``Done`` (for ``done``) / suppression (for
``cancelled``).
never-raise contract (self-hosting safety): any error / inability to determine
the DB stage degrades to ``ALLOW`` (fail-safe to the prior 1:1 behaviour, NFR-1)
— a local SQLite read is reliable, so in the normal case the stage is read and
the pendulum cannot arise.
"""
from __future__ import annotations
import logging
import re
from .config import settings
logger = logging.getLogger("orchestrator.deploy_status_guard")
# Verdicts returned by decide() (the setter executes them).
ALLOW = "ALLOW" # PATCH the requested deploy-phase status (normal path).
CONVERGE_DONE = "CONVERGE_DONE" # set_issue_done instead (idempotent convergence).
SUPPRESS = "SUPPRESS" # do nothing (do not stamp over a `cancelled` terminal).
# Deploy-phase target tokens (one per guarded setter).
AWAITING = "awaiting"
DEPLOYING = "deploying"
MONITORING = "monitoring"
# Terminal DB stages (harmonised with serial_gate / adr-0026).
_TERMINAL = ("done", "cancelled")
# Repo tokens embedded into config CSV must match this (mirrors serial_gate R-6).
_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$")
# ---------------------------------------------------------------------------
# Conditionality (mirrors post_deploy_applies / _merge_gate_applies)
# ---------------------------------------------------------------------------
def _scope_repos() -> set[str]:
"""Sanitised set of in-scope repo tokens from ``deploy_status_guard_repos``.
Empty/blank CSV -> empty set, meaning "self-hosting only" (resolved by the
caller via :func:`applies`). Invalid tokens (regex miss) are dropped. Never
raises.
"""
try:
raw = (settings.deploy_status_guard_repos or "").strip()
except Exception: # noqa: BLE001
return set()
if not raw:
return set()
out: set[str] = set()
for tok in raw.split(","):
t = tok.strip()
if t and _REPO_TOKEN.match(t):
out.add(t)
elif t:
logger.warning("deploy_status_guard: dropping invalid repo token %r", t)
return out
def applies(repo: str) -> bool:
"""Whether the guard is REAL for this repo (D6).
* ``deploy_status_guard_enabled=False`` -> always False (kill-switch; the
setters are terminal-blind, 1:1 as before ORCH-094).
* ``deploy_status_guard_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``), where
deploy-phase statuses are set at all. Mirrors the ORCH-35/36/43/58
self-hosting-only rollout -> non-self repos (enduro-trails) are untouched
(they never see Awaiting/Deploying/Monitoring; terminal-sync goes straight
to Done), i.e. zero regression.
Never raises -> False on error (degrade to "guard inert").
"""
try:
if not getattr(settings, "deploy_status_guard_enabled", False):
return False
scope = _scope_repos()
if scope:
return (repo or "").strip() in scope
# Lazy import keeps this module a leaf (avoid importing qg at load time).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("deploy_status_guard.applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# Verdict (the single predicate — ADR-001 D2)
# ---------------------------------------------------------------------------
def decide(work_item_id: str, target_status: str, reason: str | None = None) -> str:
"""Decide what a deploy-phase setter should do for ``work_item_id`` (D2).
Returns one of :data:`ALLOW` / :data:`CONVERGE_DONE` / :data:`SUPPRESS`.
Steps (ADR-001 D2):
1. kill-switch off -> ALLOW (behaviour 1:1).
2. task not found -> ALLOW (foreign/unknown issue).
3. guard not applicable for the repo -> ALLOW (non-self / out-of-scope).
4. DB stage non-terminal -> ALLOW (live deploy cycle, AC-4).
5. DB stage == 'cancelled' -> SUPPRESS (do not stamp over it).
6. DB stage == 'done':
* target == 'monitoring' AND window active -> ALLOW (legit post-deploy).
* otherwise -> CONVERGE_DONE.
7. any exception / undeterminable stage -> ALLOW (fail-safe, NFR-1).
Always emits exactly one structured observability line (FR-4 / D5): work_item,
caller (``reason``), target_status, db_stage, window_active, verdict.
"""
db_stage = None
window = None
verdict = ALLOW
try:
if not getattr(settings, "deploy_status_guard_enabled", False):
return ALLOW # step 1 (logged in finally)
from . import db
task = db.get_task_by_work_item_id(work_item_id)
if task is None:
return ALLOW # step 2
repo = task.get("repo")
if not applies(repo):
return ALLOW # step 3
db_stage = (task.get("stage") or "").strip()
if db_stage not in _TERMINAL:
verdict = ALLOW # step 4 — non-terminal: legit working deploy cycle
return verdict
if db_stage == "cancelled":
verdict = SUPPRESS # step 5
return verdict
# step 6 — db_stage == 'done'
if target_status == MONITORING:
from . import post_deploy
window = post_deploy.window_active(repo, work_item_id)
if window:
verdict = ALLOW
return verdict
verdict = CONVERGE_DONE
return verdict
except Exception as e: # noqa: BLE001 - never-raise; fail-safe to ALLOW
logger.warning(
"deploy_status_guard.decide error for %s (target=%s) -> ALLOW: %s",
work_item_id, target_status, e,
)
verdict = ALLOW
return verdict
finally:
# FR-4 / D5: one structured line per call. Convergence/suppression is the
# interesting case — log it at WARNING so a future flapp is easy to attribute.
try:
msg = (
"deploy_status_guard: work_item=%s caller=%s target=%s db_stage=%s "
"window_active=%s verdict=%s"
)
argv = (work_item_id, reason, target_status, db_stage, window, verdict)
if verdict == ALLOW:
logger.info(msg, *argv)
else:
logger.warning(msg, *argv)
except Exception: # noqa: BLE001 - logging must never raise
pass

65
src/gitea.py Normal file
View File

@@ -0,0 +1,65 @@
"""ORCH-090 (ADR-001 D8 / adr-0026): minimal Gitea branch helpers.
Leaf module — a single never-raise helper used by the STOP-cancellation path to
delete a cancelled task's REMOTE feature branch. Deliberately tiny and dependency
-light (only ``config`` + ``httpx``) so it can be imported from the stage engine
without cycles.
Self-hosting safety (NFR-3): this helper deletes ONLY the named feature branch
via the Gitea API. It NEVER touches ``main`` (a guard rejects it outright) and
NEVER force-pushes — there is no push path here at all.
"""
import logging
import httpx
from .config import settings
logger = logging.getLogger("orchestrator.gitea")
# Branches that must never be deleted by an automated cancel (self-hosting safety).
_PROTECTED_BRANCHES = {"main", "master"}
def delete_remote_branch(repo: str, branch: str) -> bool:
"""Delete a remote feature branch in Gitea (never-raise).
``DELETE /api/v1/repos/{owner}/{repo}/branches/{branch}``. Used by
``stage_engine.cancel_task`` to reset a cancelled task's progress (D8). A 404
(branch already gone) is treated as success — the goal state (branch absent) is
reached. Returns True iff the branch is confirmed absent after the call.
Guards:
* empty repo/branch -> no-op (False);
* a protected branch (``main``/``master``) -> refused with an error log
(NFR-3: STOP must never delete ``main``).
Any network/API error is logged and swallowed (the worktree is cleaned locally
regardless); returns False so the caller can note a best-effort miss.
"""
if not repo or not branch:
return False
if branch.strip().lower() in _PROTECTED_BRANCHES:
logger.error(
"delete_remote_branch REFUSED for protected branch %r in %s (self-hosting safety)",
branch, repo,
)
return False
owner = settings.gitea_owner
url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/branches/{branch}"
headers = {"Authorization": f"token {settings.gitea_token}"}
try:
resp = httpx.delete(url, headers=headers, timeout=10)
if resp.status_code in (204, 200):
logger.info("Deleted remote branch %s in %s/%s", branch, owner, repo)
return True
if resp.status_code == 404:
logger.info("Remote branch %s already absent in %s/%s", branch, owner, repo)
return True
logger.warning(
"delete_remote_branch %s in %s/%s returned %s: %s",
branch, owner, repo, resp.status_code, resp.text[:200],
)
return False
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("delete_remote_branch error for %s/%s/%s: %s", owner, repo, branch, e)
return False

View File

@@ -325,6 +325,16 @@ class JobReaper:
attempts = int(job.get("attempts") or 0) attempts = int(job.get("attempts") or 0)
max_attempts = int(job.get("max_attempts") or 2) max_attempts = int(job.get("max_attempts") or 2)
err = f"reaped: {reason} (run_id={run_id})" err = f"reaped: {reason} (run_id={run_id})"
# ORCH-090 (adr-0026 / TR-2): the source of truth for "do not revive" is the
# task's TERMINAL stage, not the job status. If the task is already terminal
# ({done,cancelled}) — e.g. STOP flipped it to 'cancelled' while this job was
# still 'running' (dead pid) — flip the job to the terminal 'cancelled'
# outcome instead of requeueing it (closes the SIGTERM/reaper requeue race).
_branch, _stage, _wid = self._task_meta(job)
if _stage in ("done", "cancelled"):
if reap_running_job(job_id, "cancelled", run_id=run_id, error=err):
self._note_reap(job, "cancelled", reason=f"{reason} (task terminal={_stage})")
return
if attempts < max_attempts: if attempts < max_attempts:
if reap_running_job(job_id, "queued", run_id=run_id, error=err): if reap_running_job(job_id, "queued", run_id=run_id, error=err):
self._note_reap(job, "queued", reason=reason) self._note_reap(job, "queued", reason=reason)

View File

@@ -171,6 +171,7 @@ async def queue():
from . import task_deps from . import task_deps
from . import serial_gate from . import serial_gate
from . import labels from . import labels
from . import cancel
from .disk_watchdog import disk_watchdog from .disk_watchdog import disk_watchdog
from .build_cache_pruner import build_cache_pruner from .build_cache_pruner import build_cache_pruner
return { return {
@@ -191,6 +192,10 @@ async def queue():
# ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch, # ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch,
# label names, scope. Additive block. # label names, scope. Additive block.
"auto_labels": labels.snapshot(), "auto_labels": labels.snapshot(),
# ORCH-090 (AC-10): STOP-cancellation observability (read-only) — kill-switch,
# repo scope, cancelled/deferred counts, recent cancellations. Additive block;
# never-raise.
"stop": cancel.snapshot(),
# ORCH-063 (FR-6 / AC-7): disk-watchdog observability (read-only) — # ORCH-063 (FR-6 / AC-7): disk-watchdog observability (read-only) —
# enabled, threshold, interval, last measurement per host-path. Additive # enabled, threshold, interval, last measurement per host-path. Additive
# block; never-raise (status() returns {"enabled": ...} minimum on error). # block; never-raise (status() returns {"enabled": ...} minimum on error).

View File

@@ -340,6 +340,21 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None:
logger.warning("merge-lease release error for %s: %s", repo, e) logger.warning("merge-lease release error for %s: %s", repo, e)
def current_lease_holder(repo: str) -> str | None:
"""ORCH-090: branch currently holding the per-repo merge-lease, or None.
Read-only helper used by ``cancel.in_critical_window`` to decide whether a STOP
must be DEFERRED (the task is mid-merge). Never raises -> None on missing/corrupt
lease or any error (the caller treats an error as fail-CLOSED itself).
"""
try:
existing = _read_lease(_lease_path(repo))
return existing.get("branch") if existing else None
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("current_lease_holder error for %s: %s", repo, e)
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ORCH-065: proactive stale/dead merge-lease reclaim (Problem B) # ORCH-065: proactive stale/dead merge-lease reclaim (Problem B)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -587,6 +602,51 @@ def merge_verify_applies(repo: str) -> bool:
return False return False
def _branch_fully_in_main(repo: str, branch: str) -> bool | None:
"""Return True iff ``branch`` has NO commits beyond ``origin/main`` (ORCH-093 D3).
Used by ``ensure_open_pr`` to avoid creating an empty PR on a branch that is
already fully merged into ``main`` (the ORCH-063 garbage-PR symptom on a
re-driven finalizer after a manual merge). In the per-branch worktree:
``git fetch origin main`` then ``git merge-base --is-ancestor HEAD origin/main``
(equivalent to ``git rev-list --count origin/main..HEAD == 0``; same idiom as
``branch_is_behind_main`` / ``verify_merged_to_main``).
* ``rc == 0`` -> HEAD is an ancestor of origin/main -> fully in main -> ``True``.
* ``rc == 1`` -> there are commits beyond main -> ``False``.
* git/OS error / ambiguous rc -> ``None`` (caller fail-OPENs: degrade to the
create path; an infra hiccup must NOT become a false no-op merge).
Never-raise: any error -> ``None``.
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-OPEN
logger.warning("_branch_fully_in_main: worktree error for %s/%s: %s", repo, branch, e)
return None
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_FETCH_TIMEOUT,
)
r = subprocess.run(
["git", "-C", wt, "merge-base", "--is-ancestor", "HEAD", "origin/main"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("_branch_fully_in_main: git error for %s/%s: %s", repo, branch, e)
return None
if r.returncode == 0:
return True
if r.returncode == 1:
return False
logger.warning(
"_branch_fully_in_main: ambiguous merge-base rc=%s for %s/%s (fail-open)",
r.returncode, repo, branch,
)
return None
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
"""Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists. """Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists.
@@ -610,6 +670,12 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
``("existed", …)``; no duplicate is created (AC-2 / FR-5). ``("existed", …)``; no duplicate is created (AC-2 / FR-5).
4. Any other HTTP/parse/network error -> ``("failed", "<reason>")``. 4. Any other HTTP/parse/network error -> ``("failed", "<reason>")``.
ORCH-093 (D3) adds a guard BETWEEN steps 1 and 2: if the branch is already fully
in ``main`` (no commits beyond ``origin/main``) there is nothing to PR -> the new
outcome ``("already-in-main", "<reason>")`` is returned WITHOUT a ``POST`` (avoids
an empty garbage PR on a re-driven finalizer). A git error of the guard fails OPEN
(degrade to the create path) so an infra hiccup never becomes a false no-op.
Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``). Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``).
Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is
NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``. NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``.
@@ -642,6 +708,21 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing) logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing)
return "existed", str(existing) return "existed", str(existing)
# Step 1b (ORCH-093 D3): guard "branch already fully in main". If the branch
# has no commits beyond origin/main there is nothing to PR — creating one
# would yield an empty garbage PR (the ORCH-063 symptom on a re-driven
# finalizer after a manual merge). Return the new "already-in-main" outcome
# so _handle_merge_verify skips merge_pr and lets the authoritative
# SHA-in-main check confirm -> done. fail-OPEN on git error / ambiguous
# (None): degrade to the create path below, NEVER block — an infra hiccup
# must not become a false no-op merge (SHA-in-main downstream stays the proof).
if _branch_fully_in_main(repo, branch) is True:
logger.info(
"ensure_open_pr: %s/%s already fully in main -> already-in-main (no PR created)",
repo, branch,
)
return "already-in-main", "branch already in main (no commits beyond origin/main)"
# Step 2: create the code-PR onto main. # Step 2: create the code-PR onto main.
parts = branch.split("/") parts = branch.split("/")
title = parts[-1] if parts else branch title = parts[-1] if parts else branch
@@ -682,6 +763,89 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
return "failed", f"ensure_open_pr error: {e}" return "failed", f"ensure_open_pr error: {e}"
# ---------------------------------------------------------------------------
# ORCH-093: transient-error retry of the merge POST + classification helpers.
# ---------------------------------------------------------------------------
def _merge_backoff(attempt: int) -> float:
"""Exponential backoff (s) with a ceiling for the merge-POST retry (ORCH-093 D1).
``backoff(i) = min(base * 2**(i-1), max)`` — the transient-breaker idiom of the
Claude agents, bounded so the total sleep ``(N-1) * max`` can never wedge the
monitor-thread running merge-verify (NFR-4). Defaults base=2, max=5 -> the
sequence is 2, 4, 5, 5, … seconds.
"""
base = settings.merge_retry_backoff_base_s
cap = settings.merge_retry_backoff_max_s
try:
return float(min(base * (2 ** (max(attempt, 1) - 1)), cap))
except Exception: # noqa: BLE001 - never-raise; degrade to the ceiling
return float(cap)
def _pr_mergeable(repo: str, index) -> bool | None:
"""Read the ``mergeable`` field of PR ``index`` via ``GET /pulls/{index}`` (ORCH-093 D2).
Used ONLY to disambiguate a ``409``/``422`` merge POST: Gitea may still be
recomputing mergeability right after a push (the ORCH-063 root cause). Returns
the boolean ``mergeable`` flag, or ``None`` when it is absent / non-boolean / the
GET fails (never-raise) — the caller treats ``None`` as the default-policy
transient (D2).
"""
try:
import httpx
owner = settings.gitea_owner
headers = {"Authorization": f"token {settings.gitea_token}"}
resp = httpx.get(
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls/{index}",
headers=headers, timeout=settings.merge_pr_timeout_s,
)
if resp.status_code != 200:
return None
val = (resp.json() or {}).get("mergeable")
return val if isinstance(val, bool) else None
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("_pr_mergeable check failed for %s PR #%s: %s", repo, index, e)
return None
def _classify_merge_response(repo: str, branch: str, index, status_code: int) -> str:
"""Classify a non-2xx ``POST /pulls/{index}/merge`` outcome (ORCH-093 D2).
Returns ``"transient"`` (retry within budget) or ``"terminal"`` (fast honest
``False``; the ORCH-071/081 HOLD backstop takes over). Decision tree:
* ``405`` ("try again later"), ``408``, any ``5xx`` -> **transient**.
* ``403`` (no rights), ``404`` (PR gone) -> **terminal**.
* ``409`` / ``422`` (ambiguous) -> ``GET /pulls/{index}`` -> ``mergeable``:
- ``False`` -> **terminal** (real conflict, fast HOLD).
- ``True`` / ``None`` / GET failed -> **transient** (default-policy
fail-OPEN-in-retry: Gitea has not recomputed yet — the ORCH-063 case;
the retry budget is finite, so a real conflict still HOLDs after it).
* any other unexpected code -> **terminal** (do not loop on unknowns).
Never-raise: any error -> ``"transient"`` (conservative, within the bounded
retry budget).
"""
try:
if status_code in (405, 408) or 500 <= status_code <= 599:
return "transient"
if status_code in (403, 404):
return "terminal"
if status_code in (409, 422):
mergeable = _pr_mergeable(repo, index)
if mergeable is False:
return "terminal"
# True OR None/unavailable -> transient (default-policy, D2).
return "transient"
return "terminal"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning(
"_classify_merge_response error for %s/%s PR #%s: %s (transient)",
repo, branch, index, e,
)
return "transient"
def merge_pr(repo: str, branch: str) -> tuple[bool, str]: def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
"""Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API. """Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API.
@@ -697,8 +861,16 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
(FR-3) adds the ``base == main`` filter so the actor merges exactly the (FR-3) adds the ``base == main`` filter so the actor merges exactly the
feature code-PR and never an auto docs-PR / a PR onto a foreign base. No feature code-PR and never an auto docs-PR / a PR onto a foreign base. No
such open PR -> ``(False, "no open PR")``. such open PR -> ``(False, "no open PR")``.
3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) -> 3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) in a
200/201 -> ``(True, "merged PR #<n>")``; otherwise ``(False, "<reason>")``. bounded retry-loop (ORCH-093 D1): ``200/201`` -> ``(True, "merged PR #<n>")``;
a TRANSIENT outcome (405/408/5xx/network/timeout, or 409|422 while still
mergeable) is retried with exponential backoff up to
``merge_retry_max_attempts``; a TERMINAL outcome (403/404/real conflict) ->
immediate ``(False, "merge failed: HTTP <code>")``; exhausting the budget on
a transient -> ``(False, "merge failed after <N> attempts: HTTP <code>")``.
The kill-switch ``merge_retry_enabled=False`` forces exactly one POST
(the prior one-shot behaviour). Only the mutating POST is retried — the
idempotent steps above are not.
Never-raise (INV-1/AC-9 / TC-09): any HTTP/parse error -> ``(False, reason)``. Never-raise (INV-1/AC-9 / TC-09): any HTTP/parse error -> ``(False, reason)``.
""" """
@@ -729,21 +901,59 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
if index is None: if index is None:
return False, "no open PR" return False, "no open PR"
m = httpx.post( # ORCH-093 D1: retry ONLY the mutating POST on transient outcomes. The
f"{base}/pulls/{index}/merge", # kill-switch collapses the budget to one attempt = the prior one-shot path
json={"Do": "merge"}, # (no branching of the loop body, ADR D1).
headers=headers, n_eff = settings.merge_retry_max_attempts if settings.merge_retry_enabled else 1
timeout=timeout, if n_eff < 1:
) n_eff = 1
if m.status_code in (200, 201): for attempt in range(1, n_eff + 1):
logger.info("merge_pr: merged PR #%s for %s/%s", index, repo, branch) try:
return True, f"merged PR #{index}" m = httpx.post(
detail = (m.text or "").strip()[:200] f"{base}/pulls/{index}/merge",
logger.warning( json={"Do": "merge"},
"merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s", headers=headers,
repo, branch, index, m.status_code, detail, timeout=timeout,
) )
return False, f"merge failed: HTTP {m.status_code}" except (httpx.HTTPError, OSError) as e:
# Network/timeout -> transient within the bounded budget (never-raise).
logger.warning(
"merge_pr: attempt %s/%s network error for %s/%s PR #%s: %s (transient)",
attempt, n_eff, repo, branch, index, e,
)
if attempt < n_eff:
time.sleep(_merge_backoff(attempt))
continue
return False, f"merge failed after {n_eff} attempts: network error"
if m.status_code in (200, 201):
logger.info(
"merge_pr: merged PR #%s for %s/%s (attempt %s/%s)",
index, repo, branch, attempt, n_eff,
)
return True, f"merged PR #{index}"
detail = (m.text or "").strip()[:200]
cls = _classify_merge_response(repo, branch, index, m.status_code)
if cls == "terminal":
logger.warning(
"merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s (terminal)",
repo, branch, index, m.status_code, detail,
)
return False, f"merge failed: HTTP {m.status_code}"
# Transient: log attempt i/N (check_ci_green idiom) and retry if budget left.
logger.warning(
"merge_pr: attempt %s/%s transient HTTP %s for %s/%s PR #%s %s",
attempt, n_eff, m.status_code, repo, branch, index, detail,
)
if attempt < n_eff:
time.sleep(_merge_backoff(attempt))
continue
return False, f"merge failed after {n_eff} attempts: HTTP {m.status_code}"
# Unreachable (loop always returns), defensive only.
return False, f"merge failed after {n_eff} attempts"
except Exception as e: # noqa: BLE001 - never-raise contract except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("merge_pr unexpected error for %s/%s: %s", repo, branch, e) logger.warning("merge_pr unexpected error for %s/%s: %s", repo, branch, e)
return False, f"merge error: {e}" return False, f"merge error: {e}"
@@ -826,6 +1036,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"), ("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
("ORCH-073", "check_main_regression", "src/merge_gate.py"), ("ORCH-073", "check_main_regression", "src/merge_gate.py"),
("ORCH-082", "ensure_open_pr", "src/merge_gate.py"), ("ORCH-082", "ensure_open_pr", "src/merge_gate.py"),
("ORCH-093", "_classify_merge_response", "src/merge_gate.py"),
] ]

View File

@@ -254,6 +254,28 @@ _STAGE_ACTIVE_AGENT = {
"deploy": "deployer", "deploy": "deployer",
} }
# ORCH-091 (D2): pipeline order is read (read-only) from the single source of
# truth src/stages.py::STAGE_TRANSITIONS — NOT from _TRACKER_STAGES (which lacks
# deploy-staging/cancelled and is not authoritative about ordering, NFR-3). Used
# to suppress the "✅ <stage>" line for a stage positioned AFTER the task's
# current stage (a rollback, e.g. deploy-staging -> development), which otherwise
# rendered the absurd "✅ Внедрение … + 🔄 Разработка".
from .stages import STAGE_TRANSITIONS # noqa: E402
_PIPELINE_ORDER = list(STAGE_TRANSITIONS.keys())
def _pipeline_pos(stage) -> int:
"""Index of ``stage`` in the pipeline order; unknown -> "far future".
Never raises. An unknown/broken stage maps past the end so it is never
spuriously suppressed (degrades to the pre-ORCH-091 behaviour: ✅ kept).
"""
try:
return _PIPELINE_ORDER.index(stage)
except (ValueError, TypeError):
return len(_PIPELINE_ORDER)
def _fmt_minutes(seconds) -> str: def _fmt_minutes(seconds) -> str:
"""Render a duration in whole minutes: 0..59s -> '<1м', else '<n>м'.""" """Render a duration in whole minutes: 0..59s -> '<1м', else '<n>м'."""
@@ -268,6 +290,27 @@ def _fmt_minutes(seconds) -> str:
return f"{seconds // 60}\u043c" return f"{seconds // 60}\u043c"
def _esc(x) -> str:
"""ORCH-095: escape a DATA value for the parse_mode=HTML card text (never-raise).
Every dynamic *data* value interpolated into ``render_task_tracker``'s HTML text
(durations, status label, model, effort, token/cost metrics) is wrapped here
exactly once at the render boundary (ADR-001, category D). This closes the class
"unescaped data in HTML text": a literal like ``<1м`` from ``_fmt_minutes`` (or any
future ``< > &`` from a data source) can no longer be parsed by Telegram as an
opening tag (``400 can't parse entities`` -> EDIT_FAILED -> frozen card, ORCH-093).
Intentional markup slots (``num_html``/``link_for``/``_done_link``/already-escaped
``esc_title`` — category M) are NOT passed through ``_esc`` so they stay valid,
clickable HTML and are never double-escaped. On any error ``str()``/escape degrades
to '' rather than raising (FR-5 never-raise).
"""
try:
return html.escape(str(x))
except Exception:
return ""
def _parse_sql_ts(ts): def _parse_sql_ts(ts):
"""Parse a SQLite 'YYYY-MM-DD HH:MM:SS' UTC timestamp -> aware datetime/None.""" """Parse a SQLite 'YYYY-MM-DD HH:MM:SS' UTC timestamp -> aware datetime/None."""
if not ts: if not ts:
@@ -423,7 +466,9 @@ def render_task_tracker(task_id: int) -> str:
) )
except Exception: except Exception:
status_label = _DEFAULT_STATUS_LABEL status_label = _DEFAULT_STATUS_LABEL
status_line = f"\U0001f4cd {status_label}" # ORCH-095 (ADR-001 D3): status label is a DATA slot (offline core + live
# overlay) -> escaped at interpolation; intentional markup is never built here.
status_line = f"\U0001f4cd {_esc(status_label)}"
lines = [header, status_line, bar] lines = [header, status_line, bar]
# ORCH-026 (B-4): waiting-line for a task blocked by an unfinished declared # ORCH-026 (B-4): waiting-line for a task blocked by an unfinished declared
@@ -442,23 +487,46 @@ def render_task_tracker(task_id: int) -> str:
except Exception: except Exception:
pass pass
def _stage_line(label, run): def _stage_line(label, stage_runs):
usage = { # ORCH-091 (D3): aggregate ALL of the stage agent's runs (retries
"input_tokens": run["input_tokens"], # included) with the SAME per-run formulas as the task totals block
"cache_read_tokens": run["cache_read_tokens"], # (:388-404) -> the stage line converges with SUM(agent_runs) instead of
"cache_creation_tokens": run["cache_creation_tokens"], # showing only the last run (which understated a multi-attempt stage:
} # ORCH-069 developer \u03a3 $3.98 rendered as ~$0.00). Each agent maps to
in_tok = fmt_tokens(_input_total(usage)) # exactly one _TRACKER_STAGES row, so \u03a3(stage lines) \u2261 task totals.
out_tok = fmt_tokens(run["output_tokens"]) in_sum = 0
cost = fmt_cost(run["cost_usd"]) out_sum = 0
dur = _fmt_minutes(_duration_seconds(run["started_at"], run["finished_at"])) cost_sum = 0.0
model = short_model_name(run["model"]) dur_sum = 0
for run in stage_runs:
usage = {
"input_tokens": run["input_tokens"],
"cache_read_tokens": run["cache_read_tokens"],
"cache_creation_tokens": run["cache_creation_tokens"],
}
in_sum += _input_total(usage)
out_sum += int(run["output_tokens"] or 0)
cost_sum += float(run["cost_usd"] or 0.0)
d = _duration_seconds(run["started_at"], run["finished_at"])
if d is not None:
dur_sum += d
# ORCH-095 (ADR-001 D1/D3): every interpolated DATA value (category D) is
# escaped here at the render boundary so a literal like '<1м' from
# _fmt_minutes can no longer break parse_mode=HTML; defence-in-depth for the
# token/cost/model/effort fields too (currently safe, structurally guarded).
in_tok = _esc(fmt_tokens(in_sum))
out_tok = _esc(fmt_tokens(out_sum))
cost = _esc(fmt_cost(cost_sum))
dur = _esc(_fmt_minutes(dur_sum))
# Model/effort/"\u043f\u043e\u043f\u044b\u0442\u043a\u0430 N" come from the LAST run (agent_runs are id ASC).
last = stage_runs[-1] if stage_runs else None
model = _esc(short_model_name(last["model"])) if last is not None else ""
model_suffix = f" \u00b7 {model}" if model else "" model_suffix = f" \u00b7 {model}" if model else ""
# ORCH-087 (BR-EFF): render the resolved --effort next to the model # ORCH-087 (BR-EFF): render the resolved --effort next to the model
# ("\u00b7 opus-4-8 \u00b7 xhigh"). Stamped at launch in agent_runs.effort; empty / # ("\u00b7 opus-4-8 \u00b7 xhigh"). Stamped at launch in agent_runs.effort; empty /
# missing -> suffix omitted (like the model suffix). Historical rows with # missing -> suffix omitted (like the model suffix). Historical rows with
# NULL effort fall back to the config-resolved effort for the agent. # NULL effort fall back to the config-resolved effort for the agent.
effort = _run_effort(run) effort = _esc(_run_effort(last)) if last is not None else ""
effort_suffix = f" \u00b7 {effort}" if effort else "" effort_suffix = f" \u00b7 {effort}" if effort else ""
return ( return (
f"\u2705 {label:<13} {dur} \u00b7 " f"\u2705 {label:<13} {dur} \u00b7 "
@@ -471,6 +539,14 @@ def render_task_tracker(task_id: int) -> str:
brd_ended = task["brd_review_ended_at"] brd_ended = task["brd_review_ended_at"]
review_seconds = _duration_seconds(brd_started, brd_ended) review_seconds = _duration_seconds(brd_started, brd_ended)
# ORCH-091 (D2): the task's current position in the pipeline, used to suppress
# \u2705-lines for stages POSITIONED AFTER it (a rollback). The deploy-staging ->
# deploy normalization is applied ONLY here (not to is_active_stage): the
# collapsed "\u0412\u043d\u0435\u0434\u0440\u0435\u043d\u0438\u0435" row carries stage_key="deploy" (pos 7); on
# stage='deploy-staging' (pos 6) the row would otherwise be wrongly suppressed.
effective_stage = "deploy" if stage == "deploy-staging" else stage
current_pos = _pipeline_pos(effective_stage)
for stage_key, label, agent in _TRACKER_STAGES: for stage_key, label, agent in _TRACKER_STAGES:
run = last_done.get(agent) run = last_done.get(agent)
# The stage is "in progress" only when it is the task's current stage AND # The stage is "in progress" only when it is the task's current stage AND
@@ -500,9 +576,14 @@ def render_task_tracker(task_id: int) -> str:
lines.append( lines.append(
f"\U0001f504 {label:<13} \u2026 \u00b7 \u0438\u0434\u0451\u0442" f"\U0001f504 {label:<13} \u2026 \u00b7 \u0438\u0434\u0451\u0442"
) )
elif run is not None: elif run is not None and current_pos >= _pipeline_pos(stage_key):
lines.append(_stage_line(label, run)) # ORCH-091 (D2): show ✅ only for stages AT or BEFORE the current
# else: not started yet -> not shown. # position. A finished run on a stage POSITIONED AFTER the current one
# (rollback, e.g. deploy-staging->development) is suppressed — its runs
# still count in the task totals (intended rollback semantics). Pass the
# FULL run list so the line aggregates all attempts (D3).
lines.append(_stage_line(label, agent_runs))
# else: not started yet, or rolled back past -> not shown.
# Insert the BRD review line right after Analysis. # Insert the BRD review line right after Analysis.
if stage_key == "analysis" and brd_started: if stage_key == "analysis" and brd_started:
@@ -510,7 +591,7 @@ def render_task_tracker(task_id: int) -> str:
if review_seconds is not None: if review_seconds is not None:
# ORCH-042 (BR-10): approve-gate passed -> \u2705 (was \u23f8\ufe0f). The # ORCH-042 (BR-10): approve-gate passed -> \u2705 (was \u23f8\ufe0f). The
# still-waiting branch below keeps \u23f8\ufe0f + \u23f3 unchanged. # still-waiting branch below keeps \u23f8\ufe0f + \u23f3 unchanged.
dur = _fmt_minutes(review_seconds) dur = _esc(_fmt_minutes(review_seconds)) # ORCH-095: D-slot
lines.append( lines.append(
f"\u2705 {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" f"\u2705 {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
) )
@@ -523,21 +604,21 @@ def render_task_tracker(task_id: int) -> str:
waited = int( waited = int(
(datetime.now(timezone.utc) - start_dt).total_seconds() (datetime.now(timezone.utc) - start_dt).total_seconds()
) )
dur = _fmt_minutes(waited) if waited is not None else "\u2026" dur = _esc(_fmt_minutes(waited)) if waited is not None else "\u2026" # ORCH-095: D-slot
lines.append( lines.append(
f"\u23f8\ufe0f {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f \u23f3" f"\u23f8\ufe0f {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f \u23f3"
) )
lines.append(bar) lines.append(bar)
lines.append( lines.append(
f"\U0001f4b0 {fmt_tokens(total_in)}\u2193 / {fmt_tokens(total_out)}\u2191 \u00b7 " f"\U0001f4b0 {_esc(fmt_tokens(total_in))}\u2193 / {_esc(fmt_tokens(total_out))}\u2191 \u00b7 "
f"{fmt_cost(total_cost)}" f"{_esc(fmt_cost(total_cost))}"
) )
if done: if done:
wall = _duration_seconds(task["created_at"], task["updated_at"]) wall = _duration_seconds(task["created_at"], task["updated_at"])
wall_str = _fmt_minutes(wall) if wall is not None else "?" wall_str = _esc(_fmt_minutes(wall)) if wall is not None else "?" # ORCH-095: D-slot
review_str = _capped_review_str(review_seconds) review_str = _esc(_capped_review_str(review_seconds)) # ORCH-095: D-slot
# ORCH-087 (BR-G5): three INDEPENDENT, explicitly-labelled metrics. None is # ORCH-087 (BR-G5): three INDEPENDENT, explicitly-labelled metrics. None is
# presented as the sum of the others \u2014 queue/wait pauses are not logged, so # presented as the sum of the others \u2014 queue/wait pauses are not logged, so
# wall != agents + review; the old "\u0412\u0441\u0435\u0433\u043e {wall}" read like a (wrong) sum. # wall != agents + review; the old "\u0412\u0441\u0435\u0433\u043e {wall}" read like a (wrong) sum.
@@ -545,7 +626,7 @@ def render_task_tracker(task_id: int) -> str:
# \u0442\u0432\u043e\u0451 = human BRD-review, capped to drop anomalous stalls (T-2) # \u0442\u0432\u043e\u0451 = human BRD-review, capped to drop anomalous stalls (T-2)
# \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c = wall-clock incl. queue/wait, NOT work time (T-3) # \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c = wall-clock incl. queue/wait, NOT work time (T-3)
lines.append( lines.append(
f"\u23f1\ufe0f \u0410\u0433\u0435\u043d\u0442\u044b {_fmt_minutes(agent_seconds)} \u00b7 " f"\u23f1\ufe0f \u0410\u0433\u0435\u043d\u0442\u044b {_esc(_fmt_minutes(agent_seconds))} \u00b7 "
f"\u0442\u0432\u043e\u0451 {review_str} \u00b7 " f"\u0442\u0432\u043e\u0451 {review_str} \u00b7 "
f"\u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c {wall_str}" f"\u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c {wall_str}"
) )
@@ -944,8 +1025,16 @@ _STAGE_STATUS_LABEL = {
"development": "Development", "development": "Development",
"review": "Code-Review", "review": "Code-Review",
"testing": "Testing", "testing": "Testing",
# ORCH-091 (D1): deploy-staging was missing -> the card froze on "To Analyse".
# Plain-style active label (like Analysis/Testing, no ⏸️ pause marker); the
# "(staging)" suffix keeps it distinct from the prod-overlay "Deploying"
# (_LIVE_BRANCH_LABELS['deploying']) and from the deploy stage's pause label.
"deploy-staging": "Deploying (staging)",
"deploy": "⏸️ Awaiting Deploy — ожидание Confirm Deploy", "deploy": "⏸️ Awaiting Deploy — ожидание Confirm Deploy",
"done": "Done", "done": "Done",
# ORCH-091 (D1): offline base for the ORCH-090 system-terminal. Matches the
# overlay label _LIVE_BRANCH_LABELS['cancelled'] -> no precedence conflict.
"cancelled": "Cancelled",
} }
_DEFAULT_STATUS_LABEL = "To Analyse" _DEFAULT_STATUS_LABEL = "To Analyse"
_IN_REVIEW_LABEL = ( _IN_REVIEW_LABEL = (
@@ -987,6 +1076,25 @@ def _row_get(row, key, default=None):
return default return default
def _neutral_stage_label(stage) -> str:
"""ORCH-091 (D1): neutral fallback for a stage NOT in _STAGE_STATUS_LABEL.
A genuinely unknown / future / broken stage gets a capitalized stage name
("deploy-staging" -> "Deploy Staging") instead of the misleading "To Analyse"
(which read as a false "first status"). Empty / unparseable -> the safe
_DEFAULT_STATUS_LABEL. Never raises. NOTE: the curated map stays the source of
human-meaningful labels; this is only the safety net for unmapped stages
(FR-3 / AC-3).
"""
try:
s = str(stage).strip()
if not s:
return _DEFAULT_STATUS_LABEL
return s.replace("-", " ").title()
except Exception:
return _DEFAULT_STATUS_LABEL
def plane_status_label(task_row) -> str: def plane_status_label(task_row) -> str:
"""ORCH-067 (Р-1, layer 1): current Plane status label for the card header. """ORCH-067 (Р-1, layer 1): current Plane status label for the card header.
@@ -1006,7 +1114,13 @@ def plane_status_label(task_row) -> str:
ended = _row_get(task_row, "brd_review_ended_at") ended = _row_get(task_row, "brd_review_ended_at")
if started and not ended: if started and not ended:
return _IN_REVIEW_LABEL return _IN_REVIEW_LABEL
return _STAGE_STATUS_LABEL.get(stage, _DEFAULT_STATUS_LABEL) # ORCH-091 (D1/FR-3): a mapped stage keeps its curated label; an UNMAPPED
# (future/unknown) stage degrades to a neutral capitalized label, NOT the
# misleading "To Analyse". 'created' stays an explicit key -> "To Analyse".
label = _STAGE_STATUS_LABEL.get(stage)
if label:
return label
return _neutral_stage_label(stage)
except Exception: except Exception:
return _DEFAULT_STATUS_LABEL return _DEFAULT_STATUS_LABEL

View File

@@ -148,6 +148,13 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
# this board status (enduro / API fallback) fail-closed — no UUID, no # this board status (enduro / API fallback) fail-closed — no UUID, no
# confirm-deploy branch, no KeyError (accessed via .get). # confirm-deploy branch, no KeyError (accessed via .get).
"Confirm Deploy": "confirm_deploy", "Confirm Deploy": "confirm_deploy",
# ORCH-090: dedicated operator "STOP" status — the cancel trigger. Like
# ORCH-059's Confirm Deploy it is INTENTIONALLY ABSENT from _DEFAULT_STATES
# (fail-closed): environments without the status (enduro / API fallback)
# resolve `stop` to None via .get -> the cancel branch simply never activates
# (no UUID, no KeyError, no blind cancel). Create a STOP status with the
# `cancelled` group on the board to enable it (07-infra-requirements.md).
"STOP": "stop",
# ORCH-066: meaningful per-stage / human-input statuses (layer B). # ORCH-066: meaningful per-stage / human-input statuses (layer B).
"To Analyse": "to_analyse", "To Analyse": "to_analyse",
"Analysis": "analysis", "Analysis": "analysis",
@@ -944,32 +951,67 @@ def set_issue_code_review(work_item_id: str, project_id: str = None):
_set_issue_state_direct(work_item_id, state_id, project_id) _set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None): def _deploy_status_guarded(work_item_id: str, target: str, reason: str | None) -> bool:
"""ORCH-094: apply the terminal-window-aware guard for a deploy-phase setter.
Returns True iff the caller should PROCEED with the normal PATCH (verdict
ALLOW). On CONVERGE_DONE it drives the task to terminal ``Done`` here (the
idempotent convergence target) and returns False; on SUPPRESS it does nothing
and returns False. never-raise: any error degrades to ALLOW (proceed), keeping
behaviour 1:1 with pre-ORCH-094 (the guard leaf itself fails safe to ALLOW).
"""
try:
from . import deploy_status_guard
verdict = deploy_status_guard.decide(work_item_id, target, reason=reason)
if verdict == deploy_status_guard.CONVERGE_DONE:
set_issue_done(work_item_id)
return False
if verdict == deploy_status_guard.SUPPRESS:
return False
return True
except Exception as e: # noqa: BLE001 - never-raise; proceed (1:1) on doubt
logger.warning(f"deploy_status_guard wrapper error for {work_item_id}: {e}")
return True
def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None, reason: str = None):
"""ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending. """ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending.
Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created. Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created.
ORCH-094: terminal-window-aware — a task whose DB stage is terminal converges to
Done instead of stamping a spurious deploy status (``reason`` = caller, FR-4).
""" """
if not _deploy_status_guarded(work_item_id, "awaiting", reason):
return
project_id = _resolve_project_id(work_item_id, project_id) project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["awaiting_deploy"] state_id = get_project_states(project_id)["awaiting_deploy"]
_set_issue_state_direct(work_item_id, state_id, project_id) _set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_deploying(work_item_id: str, project_id: str = None): def set_issue_deploying(work_item_id: str, project_id: str = None, reason: str = None):
"""ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight. """ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight.
Degrades to the project's In Progress UUID when 'Deploying' is not created. Degrades to the project's In Progress UUID when 'Deploying' is not created.
ORCH-094: terminal-window-aware (see :func:`set_issue_awaiting_deploy`).
""" """
if not _deploy_status_guarded(work_item_id, "deploying", reason):
return
project_id = _resolve_project_id(work_item_id, project_id) project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["deploying"] state_id = get_project_states(project_id)["deploying"]
_set_issue_state_direct(work_item_id, state_id, project_id) _set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_monitoring(work_item_id: str, project_id: str = None): def set_issue_monitoring(work_item_id: str, project_id: str = None, reason: str = None):
"""ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open. """ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open.
Degrades to the project's Done UUID when 'Monitoring after Deploy' is not Degrades to the project's Done UUID when 'Monitoring after Deploy' is not
created (so the board shows Done, exactly as before ORCH-066). created (so the board shows Done, exactly as before ORCH-066).
ORCH-094: terminal-window-aware — the LEGITIMATE first Monitoring (DB already
``done`` by the time line 404 runs, but the post-deploy window is active) is
allowed; a stale Monitoring after the window has closed converges to Done.
""" """
if not _deploy_status_guarded(work_item_id, "monitoring", reason):
return
project_id = _resolve_project_id(work_item_id, project_id) project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["monitoring"] state_id = get_project_states(project_id)["monitoring"]
_set_issue_state_direct(work_item_id, state_id, project_id) _set_issue_state_direct(work_item_id, state_id, project_id)

View File

@@ -316,6 +316,28 @@ def has_marker(repo: str, work_item_id: str | None, name: str) -> bool:
return False return False
def window_active(repo: str, work_item_id: str | None) -> bool:
"""ORCH-094: True iff a post-deploy observation window is currently OPEN.
A window is open iff it has been armed (``ARMED`` sentinel) and has NOT yet
finished (no ``DONE`` sentinel). The terminal-window-aware deploy-status guard
(``deploy_status_guard.decide``) uses this to keep the legitimate post-deploy
``Monitoring after Deploy`` status for a task that is already DB-``done`` while
its window is live, and to converge to ``Done`` once the window has closed.
Restart-safe (the sentinels live on disk) and never-raise -> False on error
(a doubt resolves to "window closed", i.e. converge to Done — the safe-for-
indication default that matches the bug we are fixing).
"""
try:
return has_marker(repo, work_item_id, ARMED) and not has_marker(
repo, work_item_id, DONE
)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("window_active error for %s/%s: %s", repo, work_item_id, e)
return False
def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool: def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool:
"""Create/overwrite a sentinel (best-effort). Returns True on success.""" """Create/overwrite a sentinel (best-effort). Returns True on success."""
try: try:

View File

@@ -187,12 +187,18 @@ class QueueWorker:
# launch error so the job does not wedge as 'running' forever. # launch error so the job does not wedge as 'running' forever.
logger.error(f"Worker failed to launch job {job['id']}: {e}") logger.error(f"Worker failed to launch job {job['id']}: {e}")
try: try:
from .db import get_job, mark_job from .db import get_job, mark_job, get_task
j = get_job(job["id"]) j = get_job(job["id"])
attempts = j.get("attempts", 0) if j else 0 attempts = j.get("attempts", 0) if j else 0
max_attempts = j.get("max_attempts", 2) if j else 2 max_attempts = j.get("max_attempts", 2) if j else 2
if attempts < max_attempts: # ORCH-090 (adr-0026 / TR-2): never requeue a job whose task is
# already terminal ({done,cancelled}) — a STOP that landed between
# claim and launch must win over the retry budget.
task = get_task(job.get("task_id")) if job.get("task_id") else None
if task and task.get("stage") in ("done", "cancelled"):
mark_job(job["id"], "cancelled", error=f"launch error (task terminal): {e}")
elif attempts < max_attempts:
mark_job(job["id"], "queued", error=f"launch error: {e}") mark_job(job["id"], "queued", error=f"launch error: {e}")
else: else:
mark_job(job["id"], "failed", error=f"launch error: {e}") mark_job(job["id"], "failed", error=f"launch error: {e}")

View File

@@ -110,14 +110,19 @@ def repo_has_active_task(repo: str, exclude_task_id: int | None = None) -> bool:
try: try:
conn = db.get_db() conn = db.get_db()
try: try:
# ORCH-090 (adr-0026): terminal set is {done,cancelled}. A cancelled
# task must NOT count as "active" or it would block the repo's serial
# gate forever.
if exclude_task_id is not None: if exclude_task_id is not None:
row = conn.execute( row = conn.execute(
"SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done' LIMIT 1", "SELECT 1 FROM tasks WHERE repo=? AND id != ? "
"AND stage NOT IN ('done','cancelled') LIMIT 1",
(repo, exclude_task_id), (repo, exclude_task_id),
).fetchone() ).fetchone()
else: else:
row = conn.execute( row = conn.execute(
"SELECT 1 FROM tasks WHERE repo=? AND stage != 'done' LIMIT 1", "SELECT 1 FROM tasks WHERE repo=? "
"AND stage NOT IN ('done','cancelled') LIMIT 1",
(repo,), (repo,),
).fetchone() ).fetchone()
return row is not None return row is not None
@@ -264,10 +269,12 @@ def build_claim_clause() -> str:
repo_scope = f"AND jobs.repo IN ({repo_in}) " repo_scope = f"AND jobs.repo IN ({repo_in}) "
else: else:
repo_scope = "" repo_scope = ""
# ORCH-090 (adr-0026): {done,cancelled} are both terminal — an EARLIER
# cancelled task no longer holds the FIFO serial gate closed.
active_clause = ( active_clause = (
"EXISTS (SELECT 1 FROM tasks t2 " "EXISTS (SELECT 1 FROM tasks t2 "
"WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id " "WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id "
"AND t2.stage != 'done') " "AND t2.stage NOT IN ('done','cancelled')) "
) )
if _freeze_layer_enabled(): if _freeze_layer_enabled():
freeze_clause = ( freeze_clause = (
@@ -329,9 +336,10 @@ def _per_repo_snapshot(repo: str) -> dict:
try: try:
conn = db.get_db() conn = db.get_db()
try: try:
# ORCH-090 (adr-0026): terminal set {done,cancelled}.
row = conn.execute( row = conn.execute(
"SELECT work_item_id, stage FROM tasks " "SELECT work_item_id, stage FROM tasks "
"WHERE repo=? AND stage != 'done' ORDER BY id LIMIT 1", "WHERE repo=? AND stage NOT IN ('done','cancelled') ORDER BY id LIMIT 1",
(repo,), (repo,),
).fetchone() ).fetchone()
if row: if row:

View File

@@ -384,6 +384,29 @@ def advance_stage(
f"(auto-advance after {agent})" f"(auto-advance after {agent})"
) )
# ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends
# beyond the restart-time health-check to catch the "green deploy, red prod"
# class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a
# double webhook / reconciler / finalizer re-driving `done` never doubles it
# and non-applicable repos are untouched. never-raise (arm_monitor + guard).
#
# ORCH-094 (ADR-001 D3): the arm block is moved ABOVE the terminal-sync
# block (it used to run AFTER set_issue_monitoring). The order matters now
# that set_issue_monitoring is terminal-window-aware: by the time the
# legitimate first `Monitoring` is set, the task is ALREADY DB-`done`
# (update_task_stage ran above), so the guard must see the window as ACTIVE
# (ARMED & not DONE) to let it through. Arming first writes the ARMED
# sentinel -> window_active==True -> the guard returns ALLOW. A re-drive of
# deploy->done AFTER the window has closed (DONE present) -> window_active
# False -> the guard converges to Done (no resurrected Monitoring). The
# move is safe: arm_monitor only writes a sentinel + enqueues a deferred
# job; it depends on neither the Plane status nor the merge lease.
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
try:
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
except Exception as e: # noqa: BLE001 - monitoring must never crash done
logger.warning(f"Task {task_id}: post-deploy arm failed: {e}")
# --- Terminal sync: deploy -> done must reach Plane's Done ----------- # --- Terminal sync: deploy -> done must reach Plane's Done -----------
# When the deployer's check_deploy_status passes we advance to the # When the deployer's check_deploy_status passes we advance to the
# terminal 'done' stage. Previously a merged-PR webhook completed the # terminal 'done' stage. Previously a merged-PR webhook completed the
@@ -401,7 +424,7 @@ def advance_stage(
if next_stage == "done" and work_item_id: if next_stage == "done" and work_item_id:
try: try:
if post_deploy.post_deploy_applies(repo): if post_deploy.post_deploy_applies(repo):
set_issue_monitoring(work_item_id) set_issue_monitoring(work_item_id, reason="advance:deploy->done")
logger.info( logger.info(
f"Task {task_id}: deploy->done (self), Plane state -> " f"Task {task_id}: deploy->done (self), Plane state -> "
f"Monitoring after Deploy (post-deploy window)" f"Monitoring after Deploy (post-deploy window)"
@@ -416,24 +439,14 @@ def advance_stage(
# ORCH-043: the merge has landed (deploy->done). Release the merge lease as # ORCH-043: the merge has landed (deploy->done). Release the merge lease as
# a backstop in case the PR-merged webhook was lost (holder-aware no-op if a # a backstop in case the PR-merged webhook was lost (holder-aware no-op if a
# different task already owns it). Never raises. # different task already owns it). Never raises. ORCH-094: stays AFTER the
# terminal-sync (the arm-block move above does not touch the lease).
if next_stage == "done": if next_stage == "done":
try: try:
merge_gate.release_merge_lease(repo, branch) merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001 - defensive except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}") logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}")
# ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends
# beyond the restart-time health-check to catch the "green deploy, red prod"
# class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a
# double webhook / reconciler / finalizer re-driving `done` never doubles it
# and non-applicable repos are untouched. never-raise (arm_monitor + guard).
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
try:
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
except Exception as e: # noqa: BLE001 - monitoring must never crash done
logger.warning(f"Task {task_id}: post-deploy arm failed: {e}")
# --- Launch the next agent (ORCH-4 fix: current_stage, not next) ----- # --- Launch the next agent (ORCH-4 fix: current_stage, not next) -----
next_agent = get_agent_for_stage(current_stage) next_agent = get_agent_for_stage(current_stage)
if next_agent: if next_agent:
@@ -1214,8 +1227,8 @@ def _handle_self_deploy_phase_a(
# ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`, # ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`,
# which discharges `In Review` of the deploy-approval meaning (In Review # which discharges `In Review` of the deploy-approval meaning (In Review
# stays for analyst BRD/review approve-pending only). Degrades to In Review # stays for analyst BRD/review approve-pending only). Degrades to In Review
# where the status is not created. # where the status is not created. ORCH-094: reason tags the caller (FR-4).
set_issue_awaiting_deploy(work_item_id) set_issue_awaiting_deploy(work_item_id, reason="phase_a")
# ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before # ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before
# arming a fresh approve. A prior FAILED pass clears on rollback, but clearing # arming a fresh approve. A prior FAILED pass clears on rollback, but clearing
# here too guarantees the entry to every new prod-deploy pass starts clean # here too guarantees the entry to every new prod-deploy pass starts clean
@@ -1312,8 +1325,9 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
) )
# ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying` # ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying`
# (degrades to In Progress where the status is not created). # (degrades to In Progress where the status is not created).
# ORCH-094: reason tags the caller (FR-4).
if work_item_id: if work_item_id:
set_issue_deploying(work_item_id) set_issue_deploying(work_item_id, reason="phase_b")
task_desc = ( task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)." f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
@@ -1483,6 +1497,7 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
# `created`/`existed` -> proceed unchanged; `failed` -> honest HOLD with a # `created`/`existed` -> proceed unchanged; `failed` -> honest HOLD with a
# distinguishable text (NOT the not-merged HOLD). ORCH-073's SHA-in-main proof # distinguishable text (NOT the not-merged HOLD). ORCH-073's SHA-in-main proof
# below is untouched and stays authoritative. Kill-switch off -> 1:1 prior path. # below is untouched and stays authoritative. Kill-switch off -> 1:1 prior path.
skip_merge = False
if settings.merge_verify_autocreate_pr_enabled: if settings.merge_verify_autocreate_pr_enabled:
pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch) pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
logger.info( logger.info(
@@ -1492,10 +1507,25 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
return _hold_pr_create_failed( return _hold_pr_create_failed(
task_id, repo, work_item_id, branch, pr_detail, result task_id, repo, work_item_id, branch, pr_detail, result
) )
if pr_status == "already-in-main":
# ORCH-093 (D4): the branch is already fully in `main` -> nothing to
# merge and no PR was created. Skip the deterministic merge_pr; the
# authoritative SHA-in-main check below confirms the merge -> done.
# This is NOT a HOLD (the goal is already achieved); if for some
# reason the SHA is not in main the prior not-merged HOLD still fires
# (fail-closed, safe).
logger.info(
f"Task {task_id}: merge-verify already-in-main -> skip merge_pr "
"(SHA-in-main authoritative)"
)
skip_merge = True
# "created" | "existed" -> proceed normally to merge_pr. # "created" | "existed" -> proceed normally to merge_pr.
# Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9). # Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9).
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch) if skip_merge:
merged_ok, merge_msg = True, "already-in-main (skipped merge_pr)"
else:
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
logger.info( logger.info(
f"Task {task_id}: merge-verify merge_pr -> ok={merged_ok} ({merge_msg})" f"Task {task_id}: merge-verify merge_pr -> ok={merged_ok} ({merge_msg})"
) )
@@ -1656,6 +1686,28 @@ def run_deploy_finalizer(job: dict):
finished_agent="deployer", finished_agent="deployer",
) )
# ORCH-090 (ADR-001 D7 / AC-7): a STOP that arrived during the prod deploy was
# DEFERRED (cancel_requested_at). The irreversible step has now finished honestly
# above, so apply the deferred cancellation. force=True bypasses ONLY the
# critical-window guard (the INITIATED marker may still linger) — a task that
# reached terminal 'done' (SUCCESS) is an honest no-op (code is already in prod);
# a FAILED deploy rolled back to development is fully reset now.
try:
from .db import get_task as _get_task
t = _get_task(task_id)
if t and t.get("cancel_requested_at") and t.get("stage") != "cancelled":
logger.warning(
"Task %s: applying deferred STOP after deploy finalize", task_id
)
cancel_task(
task_id,
reason="deferred STOP applied after deploy finalize",
source="deferred",
force=True,
)
except Exception as e: # noqa: BLE001 - never break the finalizer
logger.warning("Task %s: deferred-cancel application failed: %s", task_id, e)
def run_post_deploy_monitor(job: dict): def run_post_deploy_monitor(job: dict):
"""ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM). """ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM).
@@ -1676,7 +1728,7 @@ def run_post_deploy_monitor(job: dict):
try: try:
conn = get_db() conn = get_db()
row = conn.execute( row = conn.execute(
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,) "SELECT work_item_id, branch, stage FROM tasks WHERE id=?", (task_id,)
).fetchone() ).fetchone()
conn.close() conn.close()
except Exception as e: # noqa: BLE001 - never-raise except Exception as e: # noqa: BLE001 - never-raise
@@ -1685,13 +1737,28 @@ def run_post_deploy_monitor(job: dict):
if not row: if not row:
logger.error(f"post-deploy-monitor: no task row for task_id={task_id}") logger.error(f"post-deploy-monitor: no task row for task_id={task_id}")
return return
work_item_id, branch = row[0], row[1] work_item_id, branch, db_stage = row[0], row[1], row[2]
# AC-15: a finished window is a no-op (defends against a duplicate job). # AC-15: a finished window is a no-op (defends against a duplicate job).
if post_deploy.has_marker(repo, work_item_id, post_deploy.DONE): if post_deploy.has_marker(repo, work_item_id, post_deploy.DONE):
logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)") logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)")
return return
# ORCH-094 (FR-3 / D4 / AC-3): a tick must have an active basis. If the task
# became terminal ANOMALOUSLY mid-window (cancelled via STOP, ORCH-090), the
# tick is a "zombie" — close the window WITHOUT a status PATCH and WITHOUT
# re-queueing the next tick (a cancelled task already reached its own terminal;
# stamping a deploy status over it would flapp). A `done` stage is the NORMAL
# state of a post-deploy window (it opens strictly past deploy->done) so it is
# NOT treated as an anomaly here.
if (db_stage or "").strip() == "cancelled":
logger.info(
f"post-deploy-monitor: {work_item_id} task cancelled mid-window -> "
f"closing window, no status PATCH, no re-queue (zombie-tick guard)"
)
post_deploy.mark_done(repo, work_item_id)
return
# One probe -> append -> classify (restart-safe via the persisted series). # One probe -> append -> classify (restart-safe via the persisted series).
probe = post_deploy.probe_signals(settings.post_deploy_base_url) probe = post_deploy.probe_signals(settings.post_deploy_base_url)
series = post_deploy.append_probe(repo, work_item_id, probe) series = post_deploy.append_probe(repo, work_item_id, probe)
@@ -1825,3 +1892,186 @@ def _notify_post_deploy(work_item_id: str, message: str) -> None:
plane_add_comment(work_item_id, message, author="deployer") plane_add_comment(work_item_id, message, author="deployer")
except Exception as e: # noqa: BLE001 - never break the tick except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}") logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}")
# ---------------------------------------------------------------------------
# ORCH-090 (ADR-001 / adr-0026): STOP-cancellation orchestration
# ---------------------------------------------------------------------------
def cancel_task(
task_id: int,
*,
reason: str = "",
source: str = "stop",
force: bool = False,
) -> dict:
"""Cancel a task: stop the active agent + full progress reset (ORCH-090).
The single orchestration point behind the Plane STOP status (``webhooks/plane.
handle_stop``). Drives the task to the system-terminal state ``cancelled``:
1. **Idempotency (BR-5 / AC-6):** an absent task or one already terminal
(``stage in {done,cancelled}``) is a no-op — no re-kill, no re-cleanup, no
duplicate notification.
2. **Critical window (ADR-001 D7 / AC-7):** if the task is mid merge/deploy
(``cancel.in_critical_window``) and not ``force``, the cancellation is
DEFERRED: stamp ``cancel_requested_at``, cancel ONLY queued jobs (never the
running deploy/merge actor), alert, and return — the deterministic deploy
finalizer applies the cancel once the irreversible step finishes honestly.
STOP NEVER touches ``main`` / force-pushes / restarts the prod container.
3. **Full reset:** SIGTERM the running agent through the graceful cascade
(``launcher.stop_process``), cancel all jobs (terminal ``cancelled``),
clear deploy-state + release a held merge-lease (best-effort), remove the
worktree, delete the remote feature branch, then tombstone the natural keys
+ flip ``stage='cancelled'`` (durable). Docs artefacts are NOT touched.
4. **Observability (AC-10):** log + Telegram + Plane comment + tracker update.
``force=True`` bypasses ONLY the critical-window guard (used by the deploy
finalizer to apply a deferred cancel after the step completes) — it never
overrides the terminal-stage idempotency. Returns a small result dict for
tests/observability. never-raise: any error is logged; a notify failure never
aborts the cancellation.
"""
from .db import (
get_task, get_active_jobs_for_task, cancel_jobs_for_task,
mark_task_cancelled, set_task_cancel_requested,
)
from . import cancel as cancel_mod
result = {"ok": False, "task_id": task_id, "deferred": False,
"stopped": 0, "cancelled_jobs": 0, "note": None}
task = get_task(task_id)
if not task:
result["note"] = "no-task"
logger.info("cancel_task: no task row for task_id=%s", task_id)
return result
stage = task.get("stage")
repo = task.get("repo")
branch = task.get("branch") or ""
work_item_id = task.get("work_item_id") or ""
# (1) Idempotency: already terminal -> no-op.
if stage in ("done", "cancelled"):
result["ok"] = True
result["note"] = f"already-terminal:{stage}"
logger.info(
"cancel_task: task %s (%s) already terminal (stage=%s) -> no-op",
task_id, work_item_id, stage,
)
return result
# (2) Critical merge/deploy window -> DEFER (unless forced by the finalizer).
if not force and cancel_mod.in_critical_window(task):
first = set_task_cancel_requested(task_id)
result["cancelled_jobs"] = cancel_jobs_for_task(task_id, only_queued=True)
result["deferred"] = True
result["ok"] = True
result["note"] = "deferred-critical-window" if first else "deferred-already-pending"
# AC-6: only alert on the FIRST deferral transition — a repeated STOP while
# still deferred must not spam duplicate Telegram/Plane notifications.
if first:
msg = (
f"⏸️ {link_for(work_item_id)}: STOP получен во время "
f"критичного шага (merge/deploy) — отмена ОТЛОЖЕНА до честного "
f"завершения шага. main/прод не трогаются."
)
_notify_cancel(work_item_id, task_id, msg)
logger.warning(
"cancel_task: task %s (%s) in critical window -> deferred cancel "
"(first=%s, queued jobs cancelled=%s)", task_id, work_item_id, first,
result["cancelled_jobs"],
)
return result
# (3) Full reset ----------------------------------------------------------
# 3a. Stop the active agent through the graceful cascade (AC-1). Capture the
# running jobs BEFORE cancelling them so we still know their pids.
stopped = 0
try:
from .agents.launcher import launcher
for job in get_active_jobs_for_task(task_id):
if job.get("status") == "running" and job.get("pid"):
try:
if launcher.stop_process(
job["pid"], job.get("run_id"), reason=f"STOP cancel task {task_id}"
):
stopped += 1
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("cancel_task: stop_process failed for job %s: %s",
job.get("id"), e)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("cancel_task: agent-stop step failed for task %s: %s", task_id, e)
result["stopped"] = stopped
# 3b. Cancel ALL jobs (terminal 'cancelled', never requeued).
result["cancelled_jobs"] = cancel_jobs_for_task(task_id)
# 3c. Clear deploy-state sentinels + release a held merge-lease (best-effort).
# Outside a critical window the task does not hold the lease / has no
# INITIATED marker, but clearing is idempotent and harmless.
try:
self_deploy.clear_state(repo, work_item_id)
except Exception as e: # noqa: BLE001
logger.warning("cancel_task: clear deploy-state failed for %s: %s", work_item_id, e)
try:
merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001
logger.warning("cancel_task: merge-lease release failed for %s: %s", branch, e)
# 3d. Remove the worktree + delete the remote feature branch (never main).
if branch:
try:
from .git_worktree import remove_worktree
remove_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("cancel_task: remove_worktree failed for %s/%s: %s",
repo, branch, e)
try:
from . import gitea
gitea.delete_remote_branch(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("cancel_task: delete_remote_branch failed for %s/%s: %s",
repo, branch, e)
# 3e. Durable terminal + natural-key tombstone (docs artefacts untouched).
mark_task_cancelled(task_id)
# (4) Observability.
note = f" ({reason})" if reason else ""
msg = (
f"\U0001f6d1 {link_for(work_item_id)}: задача ОТМЕНЕНА (STOP){note}. "
f"Агент остановлен, job'ы сняты ({result['cancelled_jobs']}), ветка/worktree "
f"удалены, прогресс сброшен. Docs сохранены. Перезапуск — только «To Analyse»."
)
_notify_cancel(work_item_id, task_id, msg)
result["ok"] = True
result["note"] = "cancelled" if not force else "cancelled-deferred-applied"
logger.warning(
"cancel_task: task %s (%s, repo=%s) CANCELLED (source=%s, force=%s): "
"stopped=%s, cancelled_jobs=%s", task_id, work_item_id, repo, source, force,
stopped, result["cancelled_jobs"],
)
return result
def _notify_cancel(work_item_id: str, task_id: int, message: str) -> None:
"""Best-effort Telegram + Plane comment + tracker update for a cancellation.
Never raises — a notification failure must not abort the cancel (ORCH-090 FR-8).
"""
try:
send_telegram(message)
except Exception as e: # noqa: BLE001
logger.warning("cancel notify telegram failed for %s: %s", work_item_id, e)
if work_item_id:
try:
plane_add_comment(work_item_id, message, author="deployer")
except Exception as e: # noqa: BLE001
logger.warning("cancel notify plane failed for %s: %s", work_item_id, e)
try:
from .notifications import update_task_tracker
update_task_tracker(task_id)
except Exception as e: # noqa: BLE001
logger.warning("cancel notify tracker failed for task %s: %s", task_id, e)

View File

@@ -19,6 +19,13 @@ STAGE_TRANSITIONS = {
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
"done": {"next": None, "agent": None, "qg": None}, "done": {"next": None, "agent": None, "qg": None},
# ORCH-090 (adr-0026): system-terminal sink for a STOP-cancelled task. This is
# NOT a new pipeline edge — no exit-gate of any edge changes — it only makes
# get_next_stage('cancelled') correctly return None (parallel to 'done'). The
# scheduler terminal predicate is `stage IN ('done','cancelled')`; the points
# that recognise it carry the ORCH-090 marker (serial_gate / task_deps /
# reconciler / job_reaper).
"cancelled": {"next": None, "agent": None, "qg": None},
} }

View File

@@ -37,9 +37,12 @@ def is_task_ready(task_id: int) -> tuple[bool, list[str]]:
"""Return ``(ready, waiting_on)`` for a task. """Return ``(ready, waiting_on)`` for a task.
``ready`` is True when the task has no declared dependency whose predecessor ``ready`` is True when the task has no declared dependency whose predecessor
is still un-done (``tasks.stage != 'done'``). ``waiting_on`` is the list of is still un-done. ORCH-090 (adr-0026): the terminal set is
predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still blocked ``{done, cancelled}`` — a CANCELLED predecessor is terminal and no longer
by — used for the Telegram waiting-line / Plane visibility. blocks the dependent (the actual SQL predicate lives in
``db.get_unfinished_dependencies`` / ``db.claim_next_job``). ``waiting_on`` is
the list of predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still
blocked by — used for the Telegram waiting-line / Plane visibility.
never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the
scheduler omitting the gate when the DB read fails; a transient error must scheduler omitting the gate when the DB read fails; a transient error must

View File

@@ -160,8 +160,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
# fallback) resolve to None, so the branch simply never activates (no KeyError, # fallback) resolve to None, so the branch simply never activates (no KeyError,
# no blind deploy). Checked before `approved` so the two gestures never alias. # no blind deploy). Checked before `approved` so the two gestures never alias.
confirm_state = proj_states.get("confirm_deploy") confirm_state = proj_states.get("confirm_deploy")
# ORCH-090: dedicated operator STOP status -> cancel the task (stop agent + full
# reset). fail-closed via .get (no UUID on a board without the status -> None ->
# branch never activates, exactly like confirm_deploy). Checked FIRST so a STOP
# is never aliased by to_analyse/approved/rejected.
stop_state = proj_states.get("stop")
# ORCH-066: start/resume trigger is `To Analyse` (human entry-point). # ORCH-066: start/resume trigger is `To Analyse` (human entry-point).
if new_state == proj_states["to_analyse"]: if stop_state and new_state == stop_state:
await handle_stop(data, project_id)
elif new_state == proj_states["to_analyse"]:
await handle_status_start(data, project_id) await handle_status_start(data, project_id)
elif confirm_state and new_state == confirm_state: elif confirm_state and new_state == confirm_state:
await handle_confirm_deploy(data, project_id) await handle_confirm_deploy(data, project_id)
@@ -212,6 +219,44 @@ async def handle_confirm_deploy(data: dict, project_id: str = ""):
) )
async def handle_stop(data: dict, project_id: str = ""):
"""ORCH-090: a human flipped the issue to the dedicated STOP status — cancel
the task (stop the active agent + full progress reset).
Resolves the task by plane_id and delegates to the unified
``stage_engine.cancel_task`` (run off the event loop via asyncio.to_thread — it
is synchronous and may sleep during the graceful SIGTERM cascade). Guards:
* kill-switch / repo-scope via ``cancel.applies(repo)`` (False -> no-op-log);
* idempotent — an absent / already-terminal task is a no-op inside cancel_task.
Contract is never-raise (NFR-5): any error is logged, the webhook flow never
crashes.
"""
import asyncio
from .. import cancel
from ..stage_engine import cancel_task
plane_id = str(data.get("id") or "")
task = get_task_by_plane_id(plane_id)
if not task:
logger.info(f"STOP for {plane_id} but no task found, ignoring (no-op)")
return
task_id = task["id"]
repo = task.get("repo", "")
if not cancel.applies(repo):
logger.info(
f"STOP for {plane_id} (task {task_id}, repo={repo}) but cancellation is "
f"not applicable (kill-switch off / out of scope); no-op"
)
return
logger.info(f"Task {task_id}: STOP status -> cancelling (stop agent + full reset)")
try:
await asyncio.to_thread(cancel_task, task_id, reason="Plane STOP status", source="stop")
except Exception as e: # never-raise: the webhook flow must not crash
logger.error(f"STOP handling failed for task {task_id}: {e}")
async def handle_status_start(data: dict, project_id: str = ""): async def handle_status_start(data: dict, project_id: str = ""):
"""An issue moved into In Progress. """An issue moved into In Progress.
@@ -279,6 +324,36 @@ async def handle_status_start(data: dict, project_id: str = ""):
) )
return return
# ORCH-090 (ADR-001 D6 / AC-5): close the relaunch hole. The legitimate "answer
# to Needs Input" resume is owned ONLY by the analyst (ORCH-066 — the sole
# Needs-Input setter). A manual move of an EXISTING task at any OTHER stage to
# "To Analyse" must NOT silently relaunch the mid-pipeline agent on the old
# branch (the incident pattern). Gate the relaunch to `analysis`; any other
# stage -> no-op-with-log + a best-effort Plane hint to use STOP -> To Analyse
# for a clean-slate restart. Under the kill-switch off this gate is inert
# (behaviour 1:1 as before ORCH-090).
from ..config import settings as _settings
if getattr(_settings, "stop_status_enabled", False) and current_stage != "analysis":
logger.info(
f"Status->To Analyse for {plane_id}: existing task on stage "
f"'{current_stage}' — NOT relaunching {stage_agent} (relaunch-hole closed, "
f"ORCH-090). Use STOP then To Analyse to restart from scratch."
)
try:
_add_comment(
work_item_id,
" Перезапуск "
"агента сменой "
"рабочего статуса "
"отключён (ORCH-090). Для "
"перезапуска с нуля: "
"STOP → To Analyse.",
author=stage_agent,
)
except Exception as e:
logger.error(f"Failed to post relaunch-hole comment for {work_item_id}: {e}")
return
task_desc = ( task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: {current_stage}\nNote: Stakeholder returned the issue to In " f"Stage: {current_stage}\nNote: Stakeholder returned the issue to In "

View File

@@ -13,9 +13,10 @@ os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
def test_tc26_stage_transitions_unchanged(): def test_tc26_stage_transitions_unchanged():
from src.stages import STAGE_TRANSITIONS from src.stages import STAGE_TRANSITIONS
# ORCH-090 (adr-0026): `cancelled` terminal sink added (parallel to `done`).
assert set(STAGE_TRANSITIONS) == { assert set(STAGE_TRANSITIONS) == {
"created", "analysis", "architecture", "development", "review", "created", "analysis", "architecture", "development", "review",
"testing", "deploy-staging", "deploy", "done", "testing", "deploy-staging", "deploy", "done", "cancelled",
} }
# The two human gates still use their existing QG names (unchanged). # The two human gates still use their existing QG names (unchanged).
assert STAGE_TRANSITIONS["analysis"]["qg"] == "check_analysis_approved" assert STAGE_TRANSITIONS["analysis"]["qg"] == "check_analysis_approved"

View File

@@ -219,11 +219,15 @@ def test_reaper_settings_env_override(monkeypatch):
# check_branch_mergeable signature is intact (AC-13). # check_branch_mergeable signature is intact (AC-13).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_tc19_stage_transitions_unchanged(): def test_tc19_stage_transitions_unchanged():
"""No new pipeline stage was introduced by ORCH-065.""" """No new pipeline EDGE was introduced by ORCH-065.
ORCH-090 (adr-0026) adds `cancelled` as a terminal SINK (parallel to `done`),
which is not a new edge — no exit-gate of any edge changed.
"""
from src.stages import STAGE_TRANSITIONS from src.stages import STAGE_TRANSITIONS
assert set(STAGE_TRANSITIONS) == { assert set(STAGE_TRANSITIONS) == {
"created", "analysis", "architecture", "development", "review", "created", "analysis", "architecture", "development", "review",
"testing", "deploy-staging", "deploy", "done", "testing", "deploy-staging", "deploy", "done", "cancelled",
} }
@@ -253,3 +257,65 @@ def test_tc19_check_branch_mergeable_signature_intact():
from src.qg.checks import check_branch_mergeable from src.qg.checks import check_branch_mergeable
params = list(inspect.signature(check_branch_mergeable).parameters) params = list(inspect.signature(check_branch_mergeable).parameters)
assert params == ["repo", "work_item_id", "branch"] assert params == ["repo", "work_item_id", "branch"]
# ---------------------------------------------------------------------------
# ORCH-093 / TC-13: merge_retry_* settings defaults + env override (AC-5).
# ---------------------------------------------------------------------------
_MERGE_RETRY_ENV = (
"ORCH_MERGE_RETRY_ENABLED",
"ORCH_MERGE_RETRY_MAX_ATTEMPTS",
"ORCH_MERGE_RETRY_BACKOFF_BASE_S",
"ORCH_MERGE_RETRY_BACKOFF_MAX_S",
)
def test_merge_retry_settings_defaults(monkeypatch):
"""Documented defaults when no ORCH_MERGE_RETRY_* env is set."""
for name in _MERGE_RETRY_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.merge_retry_enabled is True
assert s.merge_retry_max_attempts == 3
assert s.merge_retry_backoff_base_s == 2
assert s.merge_retry_backoff_max_s == 5
def test_merge_retry_settings_env_override(monkeypatch):
"""Each field is read from its ORCH_MERGE_RETRY_* env var."""
monkeypatch.setenv("ORCH_MERGE_RETRY_ENABLED", "false")
monkeypatch.setenv("ORCH_MERGE_RETRY_MAX_ATTEMPTS", "5")
monkeypatch.setenv("ORCH_MERGE_RETRY_BACKOFF_BASE_S", "1")
monkeypatch.setenv("ORCH_MERGE_RETRY_BACKOFF_MAX_S", "8")
s = Settings()
assert s.merge_retry_enabled is False
assert s.merge_retry_max_attempts == 5
assert s.merge_retry_backoff_base_s == 1
assert s.merge_retry_backoff_max_s == 8
# ---------------------------------------------------------------------------
# ORCH-094: deploy_status_guard_* settings defaults + env override.
# ---------------------------------------------------------------------------
_DEPLOY_GUARD_ENV = (
"ORCH_DEPLOY_STATUS_GUARD_ENABLED",
"ORCH_DEPLOY_STATUS_GUARD_REPOS",
)
def test_deploy_status_guard_settings_defaults(monkeypatch):
"""Documented defaults: enabled True, repos empty (self-hosting only)."""
for name in _DEPLOY_GUARD_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.deploy_status_guard_enabled is True
assert s.deploy_status_guard_repos == ""
def test_deploy_status_guard_settings_env_override(monkeypatch):
"""Each field is read from its ORCH_DEPLOY_STATUS_GUARD_* env var."""
monkeypatch.setenv("ORCH_DEPLOY_STATUS_GUARD_ENABLED", "false")
monkeypatch.setenv("ORCH_DEPLOY_STATUS_GUARD_REPOS", "orchestrator,enduro-trails")
s = Settings()
assert s.deploy_status_guard_enabled is False
assert s.deploy_status_guard_repos == "orchestrator,enduro-trails"

View File

@@ -132,7 +132,8 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
# The restart-safe approve-requested marker was written. # The restart-safe approve-requested marker was written.
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED) assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
# ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`. # ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`.
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036") # ORCH-094: the caller now tags the reason (FR-4 observability).
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036", reason="phase_a")
stage_engine.set_issue_in_review.assert_not_called() stage_engine.set_issue_in_review.assert_not_called()
@@ -161,7 +162,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
assert any(j["agent"] == "deploy-finalizer" for j in _jobs()) assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED) assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
# ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate. # ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate.
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036") # ORCH-094: the caller now tags the reason (FR-4 observability).
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036", reason="phase_b")
# 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again. # 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again.
res2 = advance_stage( res2 = advance_stage(

View File

@@ -0,0 +1,88 @@
"""ORCH-094 — observability of deploy-status setting (FR-4 / AC-5 / TC-09).
Every deploy-phase status decision emits ONE structured line carrying work_item,
caller (reason), target_status, db_stage, window_active and the verdict; a
suppression/convergence is logged explicitly so a future flapp is attributable.
"""
import logging
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_deploy_status_obs.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import deploy_status_guard as guard # noqa: E402
from src import post_deploy # noqa: E402
from src import config as cfg # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False)
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
yield
def _make_task(stage, repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"):
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
conn.commit()
conn.close()
def test_tc09_converge_logs_full_attribution(caplog):
_make_task("done")
with caplog.at_level(logging.INFO, logger="orchestrator.deploy_status_guard"):
verdict = guard.decide("ORCH-061", guard.MONITORING, reason="advance:deploy->done")
assert verdict == guard.CONVERGE_DONE
rec = [r for r in caplog.records if r.name == "orchestrator.deploy_status_guard"]
assert rec, "guard emitted no observability record"
msg = rec[-1].getMessage()
# All five attribution fields + verdict are present.
for token in (
"work_item=ORCH-061", "caller=advance:deploy->done", "target=monitoring",
"db_stage=done", "window_active=False", "verdict=CONVERGE_DONE",
):
assert token in msg, f"missing {token!r} in {msg!r}"
# A convergence is logged at WARNING (easy to grep on a future flapp).
assert rec[-1].levelno == logging.WARNING
def test_tc09_allow_active_window_logged(caplog):
_make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed")
with caplog.at_level(logging.INFO, logger="orchestrator.deploy_status_guard"):
verdict = guard.decide("ORCH-061", guard.MONITORING, reason="advance:deploy->done")
assert verdict == guard.ALLOW
rec = [r for r in caplog.records if r.name == "orchestrator.deploy_status_guard"][-1]
msg = rec.getMessage()
assert "window_active=True" in msg and "verdict=ALLOW" in msg
assert rec.levelno == logging.INFO
def test_tc09_suppress_cancelled_logged(caplog):
_make_task("cancelled")
with caplog.at_level(logging.INFO, logger="orchestrator.deploy_status_guard"):
verdict = guard.decide("ORCH-061", guard.AWAITING, reason="phase_a")
assert verdict == guard.SUPPRESS
rec = [r for r in caplog.records if r.name == "orchestrator.deploy_status_guard"][-1]
assert "verdict=SUPPRESS" in rec.getMessage()
assert "db_stage=cancelled" in rec.getMessage()
assert rec.levelno == logging.WARNING

View File

@@ -0,0 +1,217 @@
"""ORCH-094 — terminal-window-aware deploy-status guard (FR-2 / FR-5).
Covers (04-test-plan.yaml):
TC-01 deploy-status for a DB stage=done task converges to Done: a
set_issue_monitoring/awaiting/deploying attempt on a terminal task drives
Done (or no-op if already Done), never an intermediate status.
TC-02 idempotency: a repeated terminal-aware setter call on an already-Done task
never PATCHes an intermediate status (no Done<->deploy pendulum).
TC-03 a non-terminal task (stage=deploy) is NOT suppressed: the deploy setters
proceed normally (regression AC-4).
TC-04 kill-switch off -> 1:1 prior behaviour (guard inert); on -> converge.
TC-05 never-raise: an undeterminable DB stage / DB error degrades safely (ALLOW,
no flapp, no exception).
TC-12 non-self repo: zero regression — the guard is inert (self-hosting only).
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_deploy_status_guard.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import deploy_status_guard as guard # noqa: E402
from src import plane_sync # noqa: E402
from src import post_deploy # noqa: E402
from src import config as cfg # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# Guard ON, self-hosting only (empty CSV) by default.
monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False)
# post-deploy sentinels live under a fresh tmp dir (window closed by default).
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
yield
def _make_task(stage, repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"):
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
conn.commit()
conn.close()
@pytest.fixture
def spy_setters(monkeypatch):
"""Spy the low-level PATCH primitive + the Done convergence target."""
direct = MagicMock()
done = MagicMock()
monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct)
monkeypatch.setattr(plane_sync, "set_issue_done", done)
# Keep status resolution offline-deterministic.
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1")
monkeypatch.setattr(
plane_sync, "get_project_states",
lambda pid: {"awaiting_deploy": "S-aw", "deploying": "S-dep", "monitoring": "S-mon"},
)
return direct, done
# --- TC-01 ------------------------------------------------------------------
def test_tc01_done_task_converges_to_done(spy_setters):
direct, done = spy_setters
_make_task("done")
# Window is NOT active (no ARMED sentinel) -> Monitoring is spurious.
for setter in (
plane_sync.set_issue_monitoring,
plane_sync.set_issue_awaiting_deploy,
plane_sync.set_issue_deploying,
):
done.reset_mock()
direct.reset_mock()
setter("ORCH-061")
# Converged to Done; no intermediate deploy-status PATCH.
done.assert_called_once_with("ORCH-061")
direct.assert_not_called()
def test_tc01_decide_verdicts_for_done():
_make_task("done")
# No window -> all three converge.
assert guard.decide("ORCH-061", guard.MONITORING) == guard.CONVERGE_DONE
assert guard.decide("ORCH-061", guard.AWAITING) == guard.CONVERGE_DONE
assert guard.decide("ORCH-061", guard.DEPLOYING) == guard.CONVERGE_DONE
def test_tc01_decide_allows_monitoring_in_active_window(tmp_path, monkeypatch):
_make_task("done")
# Arm the window: ARMED present, DONE absent.
post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed")
assert post_deploy.window_active("orchestrator", "ORCH-061") is True
assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW
# Awaiting/Deploying are ALWAYS spurious for a done task, even with a window.
assert guard.decide("ORCH-061", guard.AWAITING) == guard.CONVERGE_DONE
# Once the window closes (DONE present) Monitoring converges too.
post_deploy.mark_done("orchestrator", "ORCH-061")
assert post_deploy.window_active("orchestrator", "ORCH-061") is False
assert guard.decide("ORCH-061", guard.MONITORING) == guard.CONVERGE_DONE
# --- TC-02 ------------------------------------------------------------------
def test_tc02_idempotent_no_pendulum(spy_setters):
direct, done = spy_setters
_make_task("done")
# Repeated calls keep converging to Done; the intermediate Monitoring PATCH
# never fires, so there is no Done<->deploy-status pendulum.
for _ in range(5):
plane_sync.set_issue_monitoring("ORCH-061")
assert direct.call_count == 0
assert done.call_count == 5 # idempotent PATCH-equivalent (same terminal state)
# --- TC-03 ------------------------------------------------------------------
def test_tc03_non_terminal_not_suppressed(spy_setters):
direct, done = spy_setters
_make_task("deploy") # a really-deploying task
plane_sync.set_issue_awaiting_deploy("ORCH-061")
plane_sync.set_issue_deploying("ORCH-061")
plane_sync.set_issue_monitoring("ORCH-061")
# All three proceed to a real PATCH; nothing converges to Done.
assert direct.call_count == 3
done.assert_not_called()
assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW
# --- TC-04 ------------------------------------------------------------------
def test_tc04_kill_switch(spy_setters, monkeypatch):
direct, done = spy_setters
_make_task("done")
# OFF -> terminal-blind, the monitoring PATCH proceeds (1:1 pre-ORCH-094).
monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", False)
plane_sync.set_issue_monitoring("ORCH-061")
assert direct.call_count == 1
done.assert_not_called()
# ON -> converge to Done.
monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True)
direct.reset_mock()
done.reset_mock()
plane_sync.set_issue_monitoring("ORCH-061")
direct.assert_not_called()
done.assert_called_once_with("ORCH-061")
# --- TC-05 ------------------------------------------------------------------
def test_tc05_never_raise_on_db_error(spy_setters, monkeypatch):
direct, done = spy_setters
_make_task("done")
def _boom(_wi):
raise RuntimeError("db down")
monkeypatch.setattr(_db, "get_task_by_work_item_id", _boom)
# decide degrades to ALLOW (fail-safe), never raises.
assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW
# The setter proceeds with the normal PATCH (1:1), no convergence, no crash.
plane_sync.set_issue_monitoring("ORCH-061")
assert direct.call_count == 1
done.assert_not_called()
def test_tc05_unknown_task_allows(spy_setters):
direct, done = spy_setters
# No task row at all -> ALLOW (foreign/unknown issue, not ours).
assert guard.decide("ORCH-999", guard.MONITORING) == guard.ALLOW
plane_sync.set_issue_monitoring("ORCH-999")
assert direct.call_count == 1
done.assert_not_called()
def test_tc05_cancelled_is_suppressed(spy_setters):
direct, done = spy_setters
_make_task("cancelled")
assert guard.decide("ORCH-061", guard.MONITORING) == guard.SUPPRESS
plane_sync.set_issue_monitoring("ORCH-061")
# Suppressed: neither an intermediate PATCH nor a Done convergence.
direct.assert_not_called()
done.assert_not_called()
# --- TC-12 ------------------------------------------------------------------
def test_tc12_non_self_repo_inert(spy_setters):
direct, done = spy_setters
# A non-self repo done task: the guard is inert (self-hosting only, empty CSV).
_make_task("done", repo="enduro-trails", wi="ET-042", branch="feature/ET-042-x")
assert guard.applies("enduro-trails") is False
assert guard.decide("ET-042", guard.MONITORING) == guard.ALLOW
plane_sync.set_issue_monitoring("ET-042")
# Behaviour unchanged: the requested PATCH proceeds, no convergence.
assert direct.call_count == 1
done.assert_not_called()
def test_tc12_csv_scope_overrides_self_hosting(monkeypatch):
_make_task("done", repo="enduro-trails", wi="ET-042", branch="feature/ET-042-x")
# Explicit CSV scope brings a non-self repo in-scope.
monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "enduro-trails")
assert guard.applies("enduro-trails") is True
assert guard.applies("orchestrator") is False # not listed -> out of scope
assert guard.decide("ET-042", guard.MONITORING) == guard.CONVERGE_DONE

View File

@@ -135,7 +135,10 @@ def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch):
assert _stage(task_id) == "done" assert _stage(task_id) == "done"
# Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet. # Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet.
stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036") # ORCH-094: the terminal-sync caller now tags the reason (FR-4 observability).
stage_engine.set_issue_monitoring.assert_called_once_with(
"ORCH-036", reason="advance:deploy->done"
)
stage_engine.set_issue_done.assert_not_called() stage_engine.set_issue_done.assert_not_called()

View File

@@ -389,3 +389,207 @@ def test_tc16_deployer_prompt_consults_guard():
assert "no second merge" in lowered, ( assert "no second merge" in lowered, (
"deployer prompt must document the already-merged no-op (AC-11)" "deployer prompt must document the already-merged no-op (AC-11)"
) )
# ===========================================================================
# ORCH-093: merge_pr transient-retry + ensure_open_pr already-in-main guard.
# TC-01..TC-12 — httpx mocked; time.sleep no-op so backoff never slows tests.
# ===========================================================================
ORCH093_BRANCH = "feature/ORCH-093-x"
class _Resp093:
"""Response stand-in with status_code / json() / text (merge_pr reads .text)."""
def __init__(self, status_code, payload=None, text=""):
self.status_code = status_code
self._payload = payload if payload is not None else []
self.text = text
def json(self):
return self._payload
@pytest.fixture
def merge093(monkeypatch):
"""Wire Gitea settings + retry defaults; no-op backoff; PR not-already-merged."""
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", True)
monkeypatch.setattr(merge_gate.settings, "merge_retry_max_attempts", 3)
monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_base_s", 2)
monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_max_s", 5)
monkeypatch.setattr(merge_gate.time, "sleep", lambda *a, **k: None)
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
def _open_code_pr_get(number=7):
"""A list-PRs GET returning exactly one open code-PR (head==branch, base==main)."""
return lambda *a, **k: _Resp093(
200, [{"head": {"ref": ORCH093_BRANCH}, "base": {"ref": "main"}, "number": number}]
)
class _PostSeq:
"""Returns queued responses (or raises queued exceptions) on each POST call."""
def __init__(self, items):
self._items = list(items)
self.calls = 0
def __call__(self, *a, **k):
self.calls += 1
item = self._items.pop(0) if self._items else self._items_last
self._items_last = item
if isinstance(item, Exception):
raise item
return item
# --- TC-01: 405, 405, 200 -> (True, ...); exactly 3 POST; no false False (AC-1) ---
def test_tc01_merge_retries_405_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405, text="try again later"),
_Resp093(405, text="try again later"),
_Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and "PR #7" in msg
assert seq.calls == 3
# --- TC-02: 503 (5xx) then 200 -> retry -> (True, ...) (AC-1) ---
def test_tc02_merge_retries_5xx_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(503, text="bad gateway"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-03: httpx Timeout in attempt 1, then 200 -> retry; never-raise (AC-1/AC-6) ---
def test_tc03_merge_retries_network_error_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([httpx.ConnectTimeout("timed out"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-04: real conflict 409 + mergeable=False -> (False, ...), no extra POST (AC-2) ---
def test_tc04_real_conflict_terminal_no_retry(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: False)
seq = _PostSeq([_Resp093(409, text="conflict")])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and "HTTP 409" in msg
assert seq.calls == 1 # terminal -> no retry
# --- TC-05: ambiguous 409 + mergeable=True -> transient -> retry -> 200 (AC-2) ---
def test_tc05_ambiguous_409_mergeable_true_retries(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: True)
seq = _PostSeq([_Resp093(409, text="recomputing"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-06: 403 (no rights) -> immediate (False, ...) without retry (AC-2) ---
def test_tc06_403_terminal_no_retry(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(403, text="forbidden")])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and "HTTP 403" in msg and seq.calls == 1
# --- TC-07: 405 on all N attempts -> (False, "merge failed after N attempts: HTTP 405") (AC-3) ---
def test_tc07_exhausts_retries_clear_reason(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405), _Resp093(405), _Resp093(405)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False
assert "after 3 attempts" in msg and "HTTP 405" in msg
assert seq.calls == 3
# --- TC-08: kill-switch off -> exactly one POST (one-shot) at 405 -> (False, ...) (AC-5/AC-3) ---
def test_tc08_killswitch_off_one_shot(merge093, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", False)
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405), _Resp093(200)]) # 2nd would succeed if retried
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and seq.calls == 1 # one-shot: never retried
# --- TC-09: ensure_open_pr — no open PR, branch fully in main -> already-in-main, no POST (AC-4) ---
def test_tc09_ensure_already_in_main_no_post(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, [])) # no open PR
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: True)
monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("must NOT POST /pulls for an already-in-main branch")))
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "already-in-main"
# --- TC-10: ensure_open_pr — no open PR, commits beyond main -> creates PR (regress) (AC-4) ---
def test_tc10_ensure_creates_when_commits_beyond_main(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, []))
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False)
post_calls = []
def fake_post(url, json=None, headers=None, timeout=None):
post_calls.append(url)
return _Resp093(201, {"number": 12})
monkeypatch.setattr(httpx, "post", fake_post)
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "created" and detail == "12"
assert len(post_calls) == 1
# --- TC-11: ensure_open_pr — git error in guard (None) -> fail-OPEN -> create path (AC-6) ---
def test_tc11_ensure_guard_git_error_fail_open(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, []))
# None == git/OS error / ambiguous -> must NOT block; degrade to create.
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: None)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp093(201, {"number": 13}))
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "created" # fail-open: did not become a false no-op
def test_tc11_branch_fully_in_main_never_raises(monkeypatch):
"""_branch_fully_in_main: any git/OS error -> None (never-raise) (AC-6)."""
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
def boom(*a, **k):
raise OSError("git exploded")
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
assert merge_gate._branch_fully_in_main("orchestrator", ORCH093_BRANCH) is None
# --- TC-12: merge_pr / ensure_open_pr — uncaught httpx error -> safe tuple (never-raise) (AC-6) ---
def test_tc12_merge_pr_never_raises(merge093, monkeypatch):
def boom(*a, **k):
raise httpx.HTTPError("kaboom")
monkeypatch.setattr(httpx, "get", boom)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and isinstance(msg, str)
def test_tc12_ensure_open_pr_never_raises(merge093, monkeypatch):
def boom(*a, **k):
raise httpx.HTTPError("kaboom")
monkeypatch.setattr(httpx, "get", boom)
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "failed" and isinstance(detail, str)

View File

@@ -131,3 +131,92 @@ def test_tc12_kill_switch_disables_under_gate(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False) monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
assert merge_gate.merge_verify_applies("orchestrator") is False assert merge_gate.merge_verify_applies("orchestrator") is False
assert merge_gate.merge_verify_applies("enduro-trails") is False assert merge_gate.merge_verify_applies("enduro-trails") is False
# ===========================================================================
# ORCH-093 / TC-14..16: _handle_merge_verify integration (deploy->done under-gate).
# already-in-main skips merge_pr; transient-retry success -> done; exhausted -> HOLD.
# ===========================================================================
import os # noqa: E402
import tempfile # noqa: E402
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch093.db"))
from unittest.mock import MagicMock # noqa: E402
from src import stage_engine, image_freshness # noqa: E402
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
_O93_REPO = "orchestrator"
_O93_WI = "ORCH-093"
_O93_BRANCH = "feature/ORCH-093-x"
@pytest.fixture
def _o93_wire(monkeypatch):
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True)
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
monkeypatch.setattr(stage_engine, name, MagicMock())
monkeypatch.setattr(
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
)
# --- TC-14: ensure_open_pr -> already-in-main -> skip merge_pr; SHA-in-main -> done (AC-4) ---
def test_tc14_already_in_main_skips_merge_pr_then_done(_o93_wire, monkeypatch):
monkeypatch.setattr(
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("already-in-main", "x")
)
merge = MagicMock()
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
res = AdvanceResult()
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
assert intervened is False # advance to done
assert res.alerted is False
assert not merge.called # merge_pr SKIPPED (nothing to merge)
assert not stage_engine.set_issue_blocked.called
# --- TC-15: merge_pr exhausted (False) + SHA not in main -> HOLD + alert (ORCH-071/081) (AC-3) ---
def test_tc15_merge_failed_and_not_in_main_holds(_o93_wire, monkeypatch):
monkeypatch.setattr(
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9")
)
monkeypatch.setattr(
stage_engine.merge_gate, "merge_pr",
lambda r, b: (False, "merge failed after 3 attempts: HTTP 405"),
)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
res = AdvanceResult()
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
assert intervened is True # HOLD, NOT done
assert res.advanced is False
assert res.note == "merge-not-verified-hold"
assert stage_engine.set_issue_blocked.called
# --- TC-16: happy path — transient retry success in merge_pr -> SHA-in-main -> done (AC-1) ---
def test_tc16_transient_retry_success_then_done(_o93_wire, monkeypatch):
monkeypatch.setattr(
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9")
)
# merge_pr already rode out the 405x2->200 transient internally -> (True, ...).
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #9"))
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
res = AdvanceResult()
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
assert intervened is False # done, no false HOLD
assert res.alerted is False
assert not stage_engine.set_issue_blocked.called

View File

@@ -32,6 +32,11 @@ def _settings(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner") monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner")
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok") monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test") monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
# ORCH-093: these tests target the HTTP create/race logic of ensure_open_pr.
# The new already-in-main guard (_branch_fully_in_main) runs real git; pin it
# to "commits beyond main" (False) so the create path is exercised as intended.
# The guard itself has dedicated coverage (test_merge_gate.py TC-09/10/11).
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False)
def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None): def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None):

View File

@@ -125,6 +125,8 @@ def test_tc22_stage_transitions_unchanged():
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
"done": {"next": None, "agent": None, "qg": None}, "done": {"next": None, "agent": None, "qg": None},
# ORCH-090 (adr-0026): terminal SINK for a STOP-cancelled task.
"cancelled": {"next": None, "agent": None, "qg": None},
} }

View File

@@ -0,0 +1,170 @@
"""ORCH-094 — deterministic post-deploy-monitor termination (FR-3 / AC-3).
Covers (04-test-plan.yaml):
TC-06 after the window finishes (HEALTHY, ticks==budget -> set_issue_done +
`done` marker) there are NO further status PATCHes for the task (a second
tick is a no-op: 0 set_issue_* calls).
TC-07 a tick at DB stage=done with a closed window OR a task cancelled mid-window
-> immediate no-op: no status PATCH and no next-tick enqueue (zombie-tick
excluded).
TC-08 arm_monitor does not re-arm a task already in done (armed/done marker ->
no-op), and a deploy->done re-drive after the window closed converges to
Done instead of resurrecting Monitoring.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_post_deploy_termination.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import post_deploy # noqa: E402
from src import config as cfg # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
# Small window so the budget is 1 tick (window // interval).
monkeypatch.setattr(stage_engine.settings, "post_deploy_window_s", 10)
monkeypatch.setattr(stage_engine.settings, "post_deploy_interval_s", 10)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 10)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 10)
# write_post_deploy_log touches a worktree/git; stub it.
monkeypatch.setattr(post_deploy, "write_post_deploy_log", MagicMock(return_value=True))
yield
@pytest.fixture
def spy_status(monkeypatch):
setters = {}
for name in ("set_issue_done", "set_issue_monitoring", "set_issue_awaiting_deploy",
"set_issue_deploying", "set_issue_blocked"):
m = MagicMock()
monkeypatch.setattr(stage_engine, name, m)
setters[name] = m
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
return setters
def _make_task(stage="done", repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
conn.close()
return [r[0] for r in rows]
def _healthy(*a, **k):
return post_deploy.ProbeResult(health_ok=True, total=2, fivexx=0, detail="ok")
# --- TC-06 ------------------------------------------------------------------
def test_tc06_clean_finish_then_no_more_patches(spy_status, monkeypatch):
monkeypatch.setattr(post_deploy, "probe_signals", _healthy)
tid = _make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed")
job = {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
# Tick 1: budget==1, ticks==1 -> HEALTHY window exhausted -> finish.
stage_engine.run_post_deploy_monitor(job)
spy_status["set_issue_done"].assert_called_once_with("ORCH-061")
assert post_deploy.has_marker("orchestrator", "ORCH-061", post_deploy.DONE)
# No next tick was enqueued (window exhausted).
assert _jobs() == []
# Tick 2 (e.g. duplicate job): DONE marker present -> no-op, ZERO new PATCHes.
spy_status["set_issue_done"].reset_mock()
stage_engine.run_post_deploy_monitor(job)
spy_status["set_issue_done"].assert_not_called()
spy_status["set_issue_monitoring"].assert_not_called()
assert _jobs() == []
# --- TC-07 ------------------------------------------------------------------
def test_tc07_cancelled_mid_window_is_noop(spy_status, monkeypatch):
monkeypatch.setattr(post_deploy, "probe_signals", _healthy)
tid = _make_task("cancelled")
post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed")
job = {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
stage_engine.run_post_deploy_monitor(job)
# Zombie-tick guard: window closed, NO status PATCH, NO next tick.
for name, m in spy_status.items():
m.assert_not_called()
assert post_deploy.has_marker("orchestrator", "ORCH-061", post_deploy.DONE)
assert _jobs() == []
def test_tc07_finished_window_is_noop(spy_status, monkeypatch):
monkeypatch.setattr(post_deploy, "probe_signals", _healthy)
tid = _make_task("done")
# Window already finished (DONE marker present) -> no active basis to tick.
post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed")
post_deploy.mark_done("orchestrator", "ORCH-061")
job = {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
stage_engine.run_post_deploy_monitor(job)
spy_status["set_issue_done"].assert_not_called()
spy_status["set_issue_monitoring"].assert_not_called()
assert _jobs() == []
# --- TC-08 ------------------------------------------------------------------
def test_tc08_arm_monitor_idempotent_no_rearm(monkeypatch):
tid = _make_task("done")
# First arm: writes ARMED + enqueues tick 1.
assert post_deploy.arm_monitor("orchestrator", "ORCH-061", "feature/ORCH-061-x", tid) is True
assert _jobs() == ["post-deploy-monitor"]
# Second arm (re-drive deploy->done): ARMED present -> no-op, no new job.
assert post_deploy.arm_monitor("orchestrator", "ORCH-061", "feature/ORCH-061-x", tid) is False
assert _jobs() == ["post-deploy-monitor"]
def test_tc08_redrive_after_window_closed_converges(spy_status, monkeypatch):
# Guard ON, self-hosting.
monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False)
_make_task("done")
# Window armed then closed (a completed post-deploy observation).
post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed")
post_deploy.mark_done("orchestrator", "ORCH-061")
# A stale re-drive calling the REAL guarded setter must converge to Done, not
# resurrect Monitoring. (Use the real plane_sync setter via stage_engine import.)
from src import plane_sync
direct = MagicMock()
done = MagicMock()
monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct)
monkeypatch.setattr(plane_sync, "set_issue_done", done)
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1")
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: {"monitoring": "S-mon"})
plane_sync.set_issue_monitoring("ORCH-061", reason="advance:deploy->done")
direct.assert_not_called()
done.assert_called_once_with("ORCH-061")

View File

@@ -56,6 +56,9 @@ _EXPECTED_TRANSITIONS = {
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
"done": {"next": None, "agent": None, "qg": None}, "done": {"next": None, "agent": None, "qg": None},
# ORCH-090 (adr-0026): terminal SINK for a STOP-cancelled task (parallel to
# `done`; not a new edge — no exit-gate changed).
"cancelled": {"next": None, "agent": None, "qg": None},
} }

View File

@@ -0,0 +1,82 @@
"""ORCH-094 — sync convergence for a done task stuck on a deploy status (TC-10).
Integration-level: ANY sync source (reconciler tick / monitor tick / a direct
deploy-status setter call) that touches a DB-done task converges Plane to Done
idempotently instead of an intermediate deploy status, and a repeated tick does
NOT swing the Done<->deploy-status pendulum. The guard lives on the setter
(ADR-001 D1/D7), so the reconciler code itself is unchanged — driving the setter
the way a stale actor would is the faithful reproduction of the 061 flapp.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_reconciler_done_converge.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import plane_sync # noqa: E402
from src import post_deploy # noqa: E402
from src import config as cfg # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False)
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture
def spy(monkeypatch):
direct = MagicMock()
done = MagicMock()
monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct)
monkeypatch.setattr(plane_sync, "set_issue_done", done)
monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1")
monkeypatch.setattr(
plane_sync, "get_project_states",
lambda pid: {"awaiting_deploy": "S-aw", "deploying": "S-dep", "monitoring": "S-mon"},
)
return direct, done
def _make_task(stage="done", repo="orchestrator", wi="ORCH-061"):
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, "feature/ORCH-061-x", stage),
)
conn.commit()
conn.close()
def test_tc10_repeated_sync_converges_no_pendulum(spy):
direct, done = spy
_make_task("done") # done, window closed (no ARMED sentinel)
# Simulate many sync ticks alternately trying to set Monitoring / Awaiting,
# exactly like the observed 061 pendulum (Awaiting <-> Monitoring forever).
for i in range(10):
if i % 2 == 0:
plane_sync.set_issue_monitoring("ORCH-061", reason="reconciler-tick")
else:
plane_sync.set_issue_awaiting_deploy("ORCH-061", reason="reconciler-tick")
# Every tick converged to Done; not a single intermediate deploy-status PATCH.
assert direct.call_count == 0
assert done.call_count == 10
# All convergence calls target the same terminal Done (no swing).
assert all(c.args == ("ORCH-061",) for c in done.call_args_list)

Some files were not shown because too many files have changed in this diff Show More