Compare commits

..

66 Commits

Author SHA1 Message Date
post-deploy-monitor
72d662ae88 docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-066
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 19s
2026-06-07 22:33:36 +00:00
deploy-finalizer
348cf8c164 deploy(ORCH-036): finalize SUCCESS for ORCH-066
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 19s
2026-06-07 22:18:32 +00:00
bc2347abd3 tester(ET): auto-commit from tester run_id=343
All checks were successful
CI / test (push) Successful in 21s
CI / test (pull_request) Successful in 18s
2026-06-07 22:02:45 +00:00
62c1fe3461 reviewer(ET): auto-commit from reviewer run_id=342 2026-06-07 22:02:45 +00:00
0dfddf93f0 feat(plane): осмысленная статусная модель Plane (слой B — индикация)
Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя
инвариант «статус — индикация, а не управление». Меняется только слой B
(отображение: src/plane_sync.py + точки выставления статуса в
stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий
src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21).

- 6 новых логических ключей статуса (to_analyse, analysis, code_review,
  awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер
  set_issue_stage_state.
- Project-relative alias-fallback (BR-12): новый ключ деградирует на
  базовый UUID того же проекта → нулевая регрессия для enduro-trails.
- Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying;
  terminal-sync для self-hosting → Monitoring after Deploy, для прочих →
  терминальный Done.
- Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked
  (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5).
- Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает
  новые активные ожидания без расширения skip-set на алиасах.
- never-raise контракт сеттеров и резолвера состояний сохранён.
- Раскатка — созданием статусов в Plane оператором, без kill-switch.

Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков),
check_deploy_status, exit-код-контракт хука, merge-gate, схема БД.

ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md
Тесты: test_plane_status_model, test_plane_to_analyse_resume,
test_plane_status_failclosed + TC в существующих наборах. 774 passed.

Refs: ORCH-066

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:02:45 +00:00
22d3b77426 architect(ET): auto-commit from architect run_id=340 2026-06-07 22:02:45 +00:00
4a06537afd analyst(ET): auto-commit from analyst run_id=339 2026-06-07 22:02:45 +00:00
b6c0e11e4d docs: init ORCH-066 business request 2026-06-07 22:02:45 +00:00
3fb3d15cb4 docs(ORCH-066): add staging gate log (staging_status: SUCCESS)
Some checks failed
CI / test (push) Has been cancelled
Staging check suite passed (8/10, exit 0): all REAL checks green;
C9a/C9b waived as known sandbox-infra (ORCH-061).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:02:33 +00:00
4815e378d9 docs(ORCH-059): staging gate log — staging_status SUCCESS
Some checks failed
CI / test (push) Has been cancelled
Staging check suite passed (exit 0); C9a/C9b sandbox-infra waived (ORCH-061).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 19:20:18 +00:00
67e98b8296 docs(ORCH-022): staging gate log — staging_status SUCCESS
Some checks failed
CI / test (push) Has been cancelled
Canonical staging_check.py run inside orchestrator-staging:
8/10 PASS, all REAL checks green, C9a/C9b infra-waived (ORCH-061),
exit 0 → advance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 18:04:35 +00:00
stream
cad5e98892 docs(history): lessons 2026-06-07 — autonomy closure (5 задач: ORCH-58/60/61/21/65 в прод)
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 19:24:49 +03:00
bb03350ec9 Merge pull request 'feat(reaper): job-reaper + stale merge-lease reclaim + idempotent merge finalization (ORCH-065)' (#66) from feature/ORCH-065-bug-zombie-jobs-merge-lease-ru into main 2026-06-07 19:16:23 +03:00
930e65298c tester(ET): auto-commit from tester run_id=324
All checks were successful
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 18s
2026-06-07 16:14:45 +00:00
cba67a4270 reviewer(ET): auto-commit from reviewer run_id=323 2026-06-07 16:14:45 +00:00
720c31393a fix(reaper): Tier-2 finalization grace + claim-before-act (no dup advance)
Tier-2 reaped a LIVE, still-finalizing monitor: _monitor_agent writes
agent_runs.exit_code FIRST, then does git push / PR / Plane comments before
_finalize_job, and the agent pid is already dead in that window — so the old
"exit_code recorded -> reap now" had no grace and could race a healthy job.
Worse, _reap_known_outcome ran the advance (advance_stage -> enqueue_job)
BEFORE the atomic claim, so a reaper that lost the race had already enqueued
the next stage (dup advance / dup enqueue), violating ADR-001 Р-1.

Fix:
- Tier-2 grace: reap only once agent_runs.exit_code has been recorded for
  >= reaper_finalize_grace_s (new setting, default 300s; > max finalization
  window). A live finalizing monitor is never reaped (FR-1.3/AC-3). New
  finished_age_s column computed in get_running_jobs.
- claim-before-act for exit0: evaluate the canonical QG READ-ONLY (the
  reconciler pattern) to choose the terminal status, then atomically claim
  'done' FIRST; only the claim winner runs the advance. A loser performs no
  side effects -> no dup advance / dup enqueue.

Docs (golden source) updated in the same change: ADR-001, global adr-0011,
README, internals, .env.example, CHANGELOG (also fixes the P3 broken adr-0011
link). New tests cover the grace window, lost-claim no-side-effects, and the
already-advanced idempotent path.

Refs: ORCH-065

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:14:45 +00:00
9b7c855df3 reviewer(ET): auto-commit from reviewer run_id=321 2026-06-07 16:14:45 +00:00
a6b444c356 fix(merge): wire pr_already_merged guard into deployer merge path (idempotent re-merge)
The pr_already_merged guard was defined + unit-tested but consulted by zero
production code, while ADR-001 Р-3 / README / CHANGELOG claimed the merge path
consults it before a repeat merge (reviewer P1, ORCH-065 attempt 2/3). The
actual merge actor is the LLM deployer agent (it merges the feature PR at the
start of the `deploy` stage), so on a reaper re-drive of an already-merged PR
the deployer would blindly re-merge → Gitea error → false БАГ-8 rollback; AC-11
("no second merge") was not met deterministically.

Wire the guard at the real consultation point — the deployer prompt — so it
runs merge_gate.pr_already_merged before any (re-)merge and no-ops when the PR
is already merged. check_branch_mergeable is left untouched (AC-13: check_*
behaviour unchanged; it runs on the first deploy-staging→deploy edge, not on a
deploy-stage re-drive where the second-merge risk lives).

- .openclaw/agents/deployer.md: idempotent pre-merge guard step + general rule.
- src/merge_gate.py: docstring names the deployer-prompt consultation point.
- docs/architecture/README.md, CHANGELOG.md: state the consultation point so
  golden-source matches implementation.
- tests/test_merge_gate.py: regression test asserting the deployer prompt wires
  the guard (so it can't silently become dead code again).

pytest tests/ -q: 743 passed.

Refs: ORCH-065
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:14:45 +00:00
dbf14e3d5a reviewer(ET): auto-commit from reviewer run_id=319 2026-06-07 16:14:45 +00:00
4bebb921ff feat(reaper): job-reaper + stale merge-lease reclaim + idempotent merge finalization
Closes the "zombie jobs" incident class: job status was set only inside
the live launcher process, so a process death left jobs.status='running'
forever; at max_concurrency=1 one zombie blocked ALL projects' queue
(self-hosting risk). Adds a background daemon (src/job_reaper.py) with
three-tier liveness (dead-pid streak / known exit_code / max-running
backstop) whose only mutating write is an atomic terminal flip guarded by
WHERE status='running' (no double-process). For exit0 the canonical QG is
the source of truth via gate-driven advance, not "exit0".

Also proactively reclaims stale merge-lease (dead pid OR TTL) via file
delete only (no git ops), and makes merge finalization idempotent
(pr_already_merged guard + up-to-date short-circuit on re-drive).

New jobs.pid column via idempotent _ensure_column (no migration); pid
stamped in launcher._spawn after Popen. Reaper start/stop in lifespan;
"reaper" snapshot in GET /queue. Kill-switches: ORCH_REAPER_ENABLED,
ORCH_REAPER_INTERVAL_S, ORCH_REAPER_DEAD_TICKS, ORCH_REAPER_MAX_RUNNING_S,
ORCH_LEASE_RECLAIM_ENABLED.

Invariants unchanged (AC-13): STAGE_TRANSITIONS, QG_CHECKS registry,
check_branch_mergeable signature/behaviour, BUG-8 rollback, hook exit
codes. restart-safe, never-raise per unit of background work.

Docs: docs/architecture/README.md, CHANGELOG.md, .env.example.
Tests: tests/test_job_reaper.py, tests/test_merge_lease_reclaim.py,
tests/test_merge_gate.py (TC-16), tests/test_merge_gate_race.py (TC-17),
tests/test_queue.py, tests/test_config.py (TC-19/TC-20). 742 passed.

Refs: ORCH-065

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:14:45 +00:00
9f846b5a50 architect(ET): auto-commit from architect run_id=317 2026-06-07 16:14:45 +00:00
b760b24a48 analyst(ET): auto-commit from analyst run_id=316 2026-06-07 16:14:45 +00:00
f0ac9d5562 docs: init ORCH-065 business request 2026-06-07 16:14:45 +00:00
987ea810bf docs(ORCH-065): staging gate SUCCESS — REAL green, C9a/C9b infra-waived
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:14:22 +00:00
f85e449d80 Merge pull request 'feat(post-deploy): post-deploy prod monitoring + auto-rollback (ORCH-021)' (#65) from feature/ORCH-021-post-deploy-rollback into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 17:42:27 +03:00
1c89ac9df9 tester(ET): auto-commit from tester run_id=313
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 17s
2026-06-07 14:40:06 +00:00
03d899812c reviewer(ET): auto-commit from reviewer run_id=312 2026-06-07 14:40:06 +00:00
b9bcdc1545 fix(deploy): drop COPY data/ from Dockerfile so worktree-context staging build succeeds
The ORCH-058 staging rebuild (check_staging_image_fresh) builds the image with
the task git-worktree as the docker build context. A fresh worktree holds only
tracked files, but the Dockerfile did `COPY data/ ./data/` — and `data/` (the
SQLite dir) is gitignored, so it is absent from that context: `docker build`
failed with exit 1 ("BUILD-STAGING: docker build failed - aborting"), bouncing
the task off deploy-staging back to development in a loop.

The COPY was dead weight regardless: `data/` is always supplied at runtime as a
bind-mount volume (./data:/app/data, see docker-compose.yml) which shadows
anything baked into the image. Replace it with `RUN mkdir -p /app/data` so the
mountpoint exists without depending on the build context.

Regression guard: test_tc08b_dockerfile_does_not_copy_gitignored_data_dir
forbids COPY of any gitignored path (the worktree-context invariant).

Refs: ORCH-021

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:40:06 +00:00
b04fae748e tester(ET): auto-commit from tester run_id=309 2026-06-07 14:40:06 +00:00
fbfcd84b16 reviewer(ET): auto-commit from reviewer run_id=308 2026-06-07 14:40:06 +00:00
2f4c553fd8 feat(post-deploy): post-deploy prod monitoring + degradation reaction (ORCH-021)
Extend pipeline responsibility past deploy->done: after the terminal
transition for an applicable repo, arm a ~15min observation window that
probes prod and reacts to a degradation the restart-time health-check
missed ("green deploy, red prod").

- src/post_deploy.py: new leaf module (config + lazy qg/db only).
  Sentinel-file restart-safe state (.post-deploy-state-<repo>/<wi>/),
  no DB migration. probe_signals/classify/decide_action/run_rollback,
  all never-raise.
- Reserved-agent job `post-deploy-monitor` (no-LLM, Variant B, calque of
  deploy-finalizer): self-requeues each tick via enqueue_job.
- Deterministic classify: DEGRADED iff >= fail_threshold consecutive
  health failures OR window 5xx ratio > 5xx_threshold; fail-safe HEALTHY.
- Self-hosting invariant (BR-5/AC-8): a tick NEVER restarts the prod
  orchestrator container -> orchestrator is ALWAYS ALERT_ONLY.
- Conditionality (ORCH-35/36/43/58): kill-switch + CSV repos, empty ->
  self-hosting only.
- QG_CHECKS / STAGE_TRANSITIONS / schema unchanged (AC-12).
- Docs: CHANGELOG, CLAUDE artefact list (16-post-deploy-log.md),
  architecture README, .env.example (ORCH_POST_DEPLOY_*).

Refs: ORCH-021

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:40:06 +00:00
2bdba532d5 architect(ET): auto-commit from architect run_id=306 2026-06-07 14:40:06 +00:00
db83b89467 analyst(ET): auto-commit from analyst run_id=305 2026-06-07 14:40:06 +00:00
961c5e9eee docs: init ORCH-021 business request 2026-06-07 14:40:06 +00:00
84a6f61ba8 docs(ORCH-021): staging gate SUCCESS — refresh 15-staging-log timestamp
Re-ran staging_check inside orchestrator-staging (exit 0); all REAL checks
green, C9a/C9b waived per ORCH-061.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:39:48 +00:00
1af356a343 docs(ORCH-021): staging gate SUCCESS — REAL green, C9a/C9b infra-waived
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:25:00 +00:00
e18947d2d9 Merge pull request 'fix(staging): tolerate sandbox-infra-only FAILs (C9a/C9b) in deploy-staging verdict (ORCH-061)' (#62) from feature/ORCH-061-bug-deploy-staging-development into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 16:30:07 +03:00
0ec34d10fc Merge pull request 'docs(ORCH-061): staging gate SUCCESS — C9a/C9b infra-waived' (#63) from docs/ORCH-061-staging-log into main 2026-06-07 16:29:55 +03:00
bf6a0c095a docs(ORCH-061): staging gate SUCCESS — REAL green, C9a/C9b infra-waived
All checks were successful
CI / test (pull_request) Successful in 16s
Validated ORCH-061 infra-tolerance against live staging (8501): all REAL
checks pass, only sandbox-infra C9a/C9b fail and are waived → exit 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 13:29:33 +00:00
39769bdf23 tester(ET): auto-commit from tester run_id=300
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 17s
2026-06-07 13:21:17 +00:00
de47737f4f reviewer(ET): auto-commit from reviewer run_id=299
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 15s
2026-06-07 13:18:47 +00:00
stream
e3f7c1c272 ci: re-trigger after gitea restart (ORCH-061)
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 17s
2026-06-07 13:14:14 +00:00
stream
32a7aa8c6b ci: trigger re-run after host disk cleanup (ORCH-061) 2026-06-07 13:08:38 +00:00
stream
fe8586ed78 ci: re-run after host disk cleanup (ORCH-061) 2026-06-07 13:04:38 +00:00
9070489968 fix(staging): tolerate sandbox-infra-only FAILs (C9a/C9b) in deploy-staging verdict
Some checks failed
CI / test (push) Failing after 39s
CI / test (pull_request) Failing after 35s
The self-hosting orchestrator looped on deploy-staging -> development because
scripts/staging_check.py exited 1 on ANY failed check, so two infra-only checks
(C9a sandbox branch / C9b analyst-job — caused by SANDBOX bot accounts not being
members of the sandbox Plane project, NOT a pipeline regress) forced
staging_status: FAILED -> rollback -> loop, burning developer retries and tokens.

Direction (б) per ADR-001: classify staging checks as REAL (all pipeline checks,
fail-closed) vs SANDBOX_INFRA (narrow allowlist {C9a, C9b}, waivable). New leaf
module src/staging_verdict.py (stdlib-only, never-raise): classify_check +
compute_staging_verdict fold per-check results into a tolerant-but-fail-closed
verdict — any REAL failure -> FAILED/exit1 (safety net holds under any flag);
only C9a/C9b failed & tolerant -> SUCCESS/exit0 with waived list; only infra &
strict -> FAILED/exit1; any internal error -> FAILED/exit1 (never a false green).

staging_check.py now auto-classifies each check (public 3-tuple _items shape kept
as an ORCH-048 b6 regression guard), exposes categorized_items(), prints
INFRA-WAIVED/VERDICT lines, and exits via the verdict; new --strict flag forces
legacy strictness per-run. Kill-switch ORCH_STAGING_INFRA_TOLERANCE_ENABLED
(default true) restores legacy strict mode globally. launcher gains
action_stage_no_changes_note so "no changes to commit" on action stages is logged
as expected, not treated as under-delivery.

Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS registry, staging_status:/
deploy_status: frontmatter, hook exit-code (0/1/2), check_staging_status; no DB
migration. Docs: README, STAGING_CHECK.md, deployer.md, .env.example, CHANGELOG.

Refs: ORCH-061

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:39:00 +00:00
1d1208c136 architect(ET): auto-commit from architect run_id=297
All checks were successful
CI / test (push) Successful in 18s
2026-06-07 12:22:46 +00:00
3ab2690a68 analyst(ET): auto-commit from analyst run_id=296
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 12:10:46 +00:00
3806522041 docs: init ORCH-061 business request
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 15:05:55 +03:00
d4c6cc0f61 Merge pull request 'fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1 (ORCH-060)' (#60) from feature/ORCH-060-reconciler-escalated-max-retri into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 15:01:11 +03:00
210aef6954 deployer(ET): auto-commit from deployer run_id=293
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
2026-06-07 11:59:00 +00:00
1820b0244e Merge pull request 'docs(ORCH-060): staging gate FAILED (8/10) — C9a/C9b E2E' (#61) from docs/ORCH-060-staging-log into main 2026-06-07 14:58:44 +03:00
2f898ede7b docs(ORCH-060): staging gate FAILED (8/10) — C9a/C9b E2E
All checks were successful
CI / test (pull_request) Successful in 17s
Canonical staging_check run inside orchestrator-staging container
(ORCH-048). Exit code 1: branch never appeared in sandbox (C9a) and
analyst job never enqueued (C9b). staging_status: FAILED → rollback
to development per ORCH-35.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:58:29 +00:00
829b914ff7 tester(ET): auto-commit from tester run_id=292
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
2026-06-07 11:54:59 +00:00
55e5e968ae reviewer(ET): auto-commit from reviewer run_id=291
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 22s
2026-06-07 11:53:34 +00:00
4db8276f98 fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
Reconciler F-1 could not tell "stuck by a lost webhook" from "escalated:
max developer retries reached, waiting for a human". With CI green and a
reviewer that kept sending REQUEST_CHANGES up to the cap, every tick
re-unblocked development -> review -> rollback -> re-unblock (incident
ET-013, infinite bounce: wasted agent runs, Telegram spam, parasitic load
on the shared self-hosting instance).

Add two pre-gate guards in Reconciler._reconcile_gate_task (after the
existing analysis/no-gate/active-job/grace guards, before the gate
pre-evaluation), each an early silent return (no advance, no unblocked_total
increment, no notifications):
- Guard 1 (escalated, deterministic, no network, checked first):
  developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES. Promote
  stage_engine._developer_retry_count to public developer_retry_count
  (single source of truth; private alias kept). Limit from the constant,
  not a literal 3.
- Guard 2 (explicit human Plane gate, Variant A, no DB migration): new
  never-raise plane_sync.fetch_issue_state + Reconciler._is_blocked_or_needs_input;
  any error/None/unresolved project -> conservative skip. New sub-flag
  ORCH_RECONCILE_SKIP_BLOCKED_ENABLED mutes only the networked Guard 2.

F-2 unchanged: Blocked/Needs Input are outside {in_progress, approved,
rejected} so they are never replayed (regression test added). DB schema,
STAGE_TRANSITIONS, QG_CHECKS, never-raise, analysis carve-out and
kill-switches untouched.

Refs: ORCH-060

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:50:02 +00:00
efe437a4aa architect(ET): auto-commit from architect run_id=289
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 11:41:02 +00:00
365c67f45d analyst(ET): auto-commit from analyst run_id=288
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 11:28:57 +00:00
d6e0df3550 docs: init ORCH-060 business request
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 14:24:00 +03:00
4d4f542b71 Merge pull request '#59 staging gate FAILED — corrected root cause' into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 14:05:59 +03:00
9e810c89f0 docs(ORCH-058): staging gate FAILED (8/10) — CORRECTED root cause (harness bug, not handler)
All checks were successful
CI / test (pull_request) Successful in 16s
Staging check exit code 1 (C9a/C9b). Live inspection inside orchestrator-staging
proves the production webhook handler is correct: get_project_states(SANDBOX).in_progress
= 84a76f65..., but scripts/staging_check.py hardcodes the enduro fallback b873d9eb...
=> handler correctly classifies the webhook as "no pipeline action". Fix belongs in
scripts/staging_check.py (resolve SANDBOX in_progress dynamically), NOT in handle_status_start
or any ORCH-058 image-freshness code. Image under test = ORCH-058 merge commit 094b5e2f.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 11:05:37 +00:00
60e5596e94 docs(ORCH-058): staging gate re-run — staging_status FAILED (8/10, C9a/C9b)
E2E pipeline not triggered on staging webhook ("no pipeline action" on
state b873d9eb...); reproduces prior FAILED. Rolls task back to development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 10:42:21 +00:00
bf60f7a48a Merge pull request 'docs(ORCH-058): staging gate re-run on fresh image — staging_status FAILED' (#58) from deployer/ORCH-058-staging-verdict into main 2026-06-07 13:22:14 +03:00
637c4e9e2e docs(ORCH-058): staging gate re-run on fresh image — staging_status FAILED (8/10)
All checks were successful
CI / test (pull_request) Successful in 16s
Strategy-A freshness re-validation rebuilt 8501 from merged commit 094b5e2 and
re-ran staging_check; E2E C9a/C9b fail (Plane "In Progress"/started webhook ->
"no pipeline action", no task/branch/analyst-job). Machine verdict FAILED ->
rollback to development. Prod (8500) untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 10:21:37 +00:00
094b5e2f96 Merge pull request 'feat(ORCH-058): staging-image provenance before BUILD-ONCE prod retag (INV-FRESH)' (#57) from feature/ORCH-058-self-deploy-retag-staging into main 2026-06-07 13:04:07 +03:00
90b6c8d5a8 docs(ORCH-058): staging gate re-run — staging_status SUCCESS (10/10 PASS)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 09:52:41 +00:00
2221d402b1 docs(ORCH-058): staging gate log — staging_status SUCCESS (10/10 PASS)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 09:33:05 +00:00
118 changed files with 11227 additions and 53 deletions

View File

@@ -85,6 +85,16 @@ ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
ORCH_IMAGE_FRESHNESS_ENABLED=true
ORCH_IMAGE_FRESHNESS_REPOS=
# ORCH-061: staging-verdict tolerance to sandbox-infra-only FAILs. The self-hosting
# orchestrator looped on deploy-staging because staging_check.py exited 1 on ANY FAIL,
# so two infra-only checks (C9a sandbox branch / C9b analyst-job — caused by SANDBOX
# bot accounts not being members of the sandbox Plane project, NOT a pipeline regress)
# forced staging_status: FAILED -> rollback -> loop. With this ON, C9a/C9b are WAIVED
# to SUCCESS when every REAL check is green; any REAL failure still fails closed.
# true (default) -> tolerant; false -> legacy strict (1:1 pre-ORCH-061, any FAIL rolls back).
# Lives in .env.staging (the staging instance). CLI --strict overrides this per-run.
ORCH_STAGING_INFRA_TOLERANCE_ENABLED=true
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background daemon
# replays a missed stage transition through the SAME gates/handlers a webhook would,
# fixing tasks that got stuck on a dropped event (502 on rebuild, no Plane/Gitea
@@ -95,9 +105,67 @@ ORCH_IMAGE_FRESHNESS_REPOS=
# GRACE_DEFAULT_S -> default "stuck" threshold on tasks.updated_at (seconds).
# GRACE_OVERRIDES_JSON -> per-stage thresholds, e.g. {"development":300}; bad JSON -> default.
# NOTIFY_UNBLOCK -> send a Telegram message when a stuck task is unblocked.
# SKIP_BLOCKED_ENABLED -> ORCH-060 F-1 Guard 2: skip reconciling issues a human moved
# to Blocked / Needs Input (per-candidate Plane state lookup).
# false mutes ONLY the networked Guard 2; Guard 1 (escalated by
# developer retries, local+deterministic) is always active.
ORCH_RECONCILE_ENABLED=true
ORCH_RECONCILE_PLANE_ENABLED=true
ORCH_RECONCILE_INTERVAL_S=120
ORCH_RECONCILE_GRACE_DEFAULT_S=600
ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread
# (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps
# zombie 'running' jobs whose monitor/process died before writing the terminal status
# (one zombie at max_concurrency=1 blocks the whole shared queue) and periodically
# reclaims dead/stale merge-leases. Liveness is three-tier: Tier-1 dead jobs.pid
# (os.kill(pid,0)) after REAPER_DEAD_TICKS consecutive dead ticks (anti-false-positive
# for a live agent); Tier-2 agent_runs.exit_code recorded but job still 'running'
# (only after a REAPER_FINALIZE_GRACE_S finalization grace, so a live monitor still
# doing git push / PR / Plane comments is never reaped); Tier-3 backstop after
# REAPER_MAX_RUNNING_S. The terminal flip carries an atomic status='running' guard and
# precedes any advance/enqueue (claim-before-act) so it never double-processes/-advances
# a row racing a late monitor or requeue_running_jobs.
# REAPER_ENABLED -> global kill-switch (false -> strictly prior behaviour).
# REAPER_INTERVAL_S -> background scan period (seconds).
# REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2).
# REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace.
# REAPER_FINALIZE_GRACE_S -> Tier-2 grace: how long agent_runs.exit_code must have been
# recorded before a still-'running' job is reaped; MUST exceed
# the max finalization window (git push + PR + Plane comments).
# LEASE_RECLAIM_ENABLED -> kill-switch for the proactive stale/dead lease reclaim
# (false -> only the legacy lazy TTL reclaim in acquire_merge_lease).
# (reuse) ORCH_MERGE_LOCK_TIMEOUT_S -> lease TTL; ORCH_MERGE_GATE_REPOS -> reclaim scope.
ORCH_REAPER_ENABLED=true
ORCH_REAPER_INTERVAL_S=60
ORCH_REAPER_DEAD_TICKS=2
ORCH_REAPER_MAX_RUNNING_S=3600
ORCH_REAPER_FINALIZE_GRACE_S=300
ORCH_LEASE_RECLAIM_ENABLED=true
# ORCH-021: post-deploy production monitoring + degradation reaction. After the
# terminal deploy->done transition for an applicable repo, a reserved-agent job
# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a
# window and reacts to a degradation the restart-time health-check missed (class
# "green deploy, red prod", precedent ET-8). State is in sentinel files
# (.post-deploy-state-<repo>/<wi>/), no DB migration.
# MONITOR_ENABLED -> global kill-switch; false -> pipeline is 1:1 as before ORCH-021.
# REPOS -> CSV of repos where monitoring is REAL; empty -> only self-hosting.
# WINDOW_S -> observation window length (~15 min).
# INTERVAL_S -> seconds between probe ticks.
# FAIL_THRESHOLD -> N CONSECUTIVE health failures -> DEGRADED.
# 5XX_THRESHOLD -> window 5xx ratio above this -> DEGRADED.
# AUTO_ROLLBACK -> allow auto-rollback; acts ONLY for non-self repos. Self-hosting
# is ALWAYS ALERT_ONLY (a tick NEVER restarts the prod container).
# BASE_URL -> base URL of the observed prod instance.
ORCH_POST_DEPLOY_MONITOR_ENABLED=true
ORCH_POST_DEPLOY_REPOS=
ORCH_POST_DEPLOY_WINDOW_S=900
ORCH_POST_DEPLOY_INTERVAL_S=30
ORCH_POST_DEPLOY_FAIL_THRESHOLD=3
ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5
ORCH_POST_DEPLOY_AUTO_ROLLBACK=false
ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500

View File

@@ -37,8 +37,19 @@ On stage `deploy-staging` your job is to run the staging test suite and write a
not exist. Details: `docs/operations/STAGING_CHECK.md`.
2. Check the exit code:
- Exit code **0** = all tests PASS → `staging_status: SUCCESS`
- Exit code **non-zero** = tests FAILED → `staging_status: FAILED`
- Exit code **0** = advance → `staging_status: SUCCESS`
- Exit code **non-zero** = rollback → `staging_status: FAILED`
> **ORCH-061**: exit 0 may now include *waived* sandbox-infra failures. The two
> infra-only checks **C9a/C9b** (sandbox branch / analyst-job, which depend on
> SANDBOX bot accounts being project members — not on the pipeline) are tolerated
> when every REAL check is green; the script prints an `INFRA-WAIVED:` line and a
> `VERDICT:` line, and still exits 0. Any REAL check failing still yields exit 1
> (fail-closed). If you see `INFRA-WAIVED:` in the output, copy that line into the
> `15-staging-log.md` body for observability. The exit-code → `staging_status`
> mapping above is unchanged: trust the exit code, do NOT re-judge waived checks.
> Kill-switch: `ORCH_STAGING_INFRA_TOLERANCE_ENABLED=false` (or `--strict`) restores
> legacy strictness. Details: `docs/operations/STAGING_CHECK.md`.
3. Write the verdict to `docs/work-items/<work_item_id>/15-staging-log.md` with YAML frontmatter:
```markdown
@@ -80,6 +91,30 @@ The verdict contract is unchanged: `docs/work-items/<work_item_id>/14-deploy-log
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
### ⚠️ Idempotent merge guard — consult `pr_already_merged` BEFORE merging (ORCH-065)
The `deploy` stage can be **re-driven**: if a process/monitor thread died after the PR
merged but before the job finalised, the job-reaper requeues it and this stage runs **again**
(ADR-001 ORCH-065, Р-3). A blind second merge of an already-merged PR makes Gitea return a
merge error → a false БАГ-8 rollback. To stay idempotent, **before you merge the feature
branch PR into `main`, consult the deterministic guard** `merge_gate.pr_already_merged(repo, branch)`:
```bash
# Already merged? exit 0 = yes (skip the merge), exit 1 = no (merge normally).
python3 -c "import sys; from src.merge_gate import pr_already_merged; \
sys.exit(0 if pr_already_merged('<repo>', '<branch>') else 1)" && MERGED=1 || MERGED=0
```
- `MERGED=1` (PR already merged) → **do NOT merge again** (no second merge, no error).
Treat the merge as already done and continue to write the deploy verdict
(`deploy_status: SUCCESS` once the deploy itself is health-ok). This is the AC-11 no-op.
- `MERGED=0` (not merged) → merge the PR normally, then proceed.
The guard is **never-raise** (any Gitea/parse error → `False` → "not known-merged", so a real
merge is never silently skipped). This is the single consultation point ADR-001 Р-3 /
README / CHANGELOG refer to: the **merge path (deployer/merge) consults the guard before a
(repeat) merge**.
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
@@ -113,4 +148,7 @@ deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
- **Idempotent merge (ORCH-065):** before any (re-)merge of a feature PR into `main`, consult
`merge_gate.pr_already_merged(repo, branch)` (see the `deploy` stage section). Already merged
→ no second merge, no error — the stage is a no-op on the merge and proceeds to its verdict.
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.

4
.task-arch.md Normal file
View File

@@ -0,0 +1,4 @@
Work item: ORCH-061
Repo: orchestrator
Branch: feature/ORCH-061-bug-deploy-staging-development
Stage: architecture

4
.task-dev.md Normal file
View File

@@ -0,0 +1,4 @@
Work item: ORCH-061
Repo: orchestrator
Branch: feature/ORCH-061-bug-deploy-staging-development
Stage: development

8
.task.md Normal file
View File

@@ -0,0 +1,8 @@
Work item: ORCH-061
Repo: orchestrator
Branch: feature/ORCH-061-bug-deploy-staging-development
Stage: analysis
Title: BUG: deploy-staging петля — откат на development (self-deploy)
Description:
Симптом: на стадии deploy-staging для self-hosting orchestrator задача откатывается deploy-staging -> development и крутится по кругу.ДВЕ подтверждённые причины (ORCH-58 + ORCH-60):1. check_staging_status FAILED (ложный). deployer гоняет staging_check.py, тот падает на C9a/C9b (sandbox e2e: branch not found + analyst job in queue) с пометкой «Plane comment check skipped: bot-tokens not added to SANDBOX project». 8/10 PASS, 2 ложных FAIL из-за ненастроенных bot-токенов SANDBOX-проекта. QG check_staging_status -> FAILED -> rollback deploy-staging->development. Это НЕ регресс кода, а отсутствие sandbox-настроек.2. no changes to commit. для action-стадий (деплой = рестарт/retag, не правка кода) deployer exit0 + «no changes» тоже трактуется stage_engine как недовыполнение -> откат.Последствие: прод-деплой self-hosting репо НЕВОЗМОЖЕН автономно — ORCH-58 и ORCH-60 доводились ВРУЧНУЮ (merge PR + build-once retag + --deploy). Прямой блокер автономного внедрения (эпик ORCH-54).Fix-направления (одно или оба):(а) Настроить sandbox bot-токены в SANDBOX Plane-проект, чтобы staging_check C9a/C9b проходили честно (10/10). Тогда check_staging_status не будет ложно падать.(б) Отвязать advance deploy-стадии от git-changes для self-deploy репо: успех = exit0 + health PASS (+ опц. staging_check), а не наличие коммита.Acceptance: ORCH-задача для self-hosting orchestrator проходит deploy-staging -> deploy -> Done БЕЗ ручного вмешательства и без петли. Priority P0.

File diff suppressed because one or more lines are too long

View File

@@ -38,6 +38,9 @@ created → analysis → architecture → development → review → testing →
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
```
## Статусная модель 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`.
## Конвенции
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
@@ -47,7 +50,7 @@ created → analysis → architecture → development → review → testing →
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
## Артефакты задачи (`docs/work-items/<plane-id>/`)
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`.
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021).
## Правила для агентов
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.

View File

@@ -20,6 +20,13 @@ RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY data/ ./data/
# ORCH-021: do NOT `COPY data/ ./data/`. `data/` is gitignored (SQLite DB dir) and
# is provided at runtime as a bind-mount volume (`./data:/app/data`, see
# docker-compose.yml) which shadows anything baked into the image — so the COPY was
# dead weight. Worse, the ORCH-058 staging rebuild (`check_staging_image_fresh`)
# builds with the task *worktree* as the docker build context; a fresh worktree never
# contains the untracked `data/`, so `COPY data/` failed `docker build` with exit 1
# and bounced the task off `deploy-staging`. We just ensure the mountpoint exists.
RUN mkdir -p /app/data
ENV PYTHONPATH=/app
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]

View File

@@ -135,6 +135,7 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | Порог «застряла» по `tasks.updated_at`, сек | `600` |
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` |
| `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-1 / F-2b)

View File

@@ -11,7 +11,8 @@
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
- **Reconciler** (`src/reconciler.py`, ORCH-053 реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
@@ -42,6 +43,16 @@ created → analysis → architecture → development → review → testing →
### Условный staging-гейт (ORCH-35)
`check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)``orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md).
### Толерантность staging-вердикта к инфра-FAIL (ORCH-061 — design)
Self-hosting зацикливался на `deploy-staging`: `scripts/staging_check.py` давал ложный FAILED на C9a/C9b (ветка в sandbox / analyst-job в очереди), вызванный **отсутствием sandbox-настроек** (bot-аккаунты не члены SANDBOX-проекта), а не регрессом кода → откат `deploy-staging → development` → петля. ORCH-061 классифицирует проверки suite на **REAL** (pipeline) и **SANDBOX_INFRA** (узкий allowlist `{C9a, C9b}`) и делает вердикт толерантным к инфра-FAIL, сохраняя fail-closed для реальных проверок:
- Чистая логика — leaf-модуль `src/staging_verdict.py` (`classify_check`, `compute_staging_verdict`, never-raise). Упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и толерантность вкл → SUCCESS/exit0 (waived); waiver применяется только когда все REAL (вкл. C7/C8) зелёные.
- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через `staging_verdict`, печатает `INFRA-WAIVED` (наблюдаемость).
- Kill-switch `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`, в `.env.staging`); `false` → 1:1 прежнее строгое поведение.
- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр `QG_CHECKS`**без изменений** (новый QG-чек не вводится); условность ORCH-35 и схема БД сохранены.
- Инвариант: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`) не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом (launcher не откатывает; добавлена observability-строка).
Подробнее: [adr-0009](adr/adr-0009-staging-infra-tolerance.md), детально — `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`.
### Merge-gate: догон `main` + re-test + сериализация слияний (ORCH-043)
Детерминированный под-гейт (`check_branch_mergeable`, без LLM) на ребре **`deploy-staging → deploy`**: исполняется ПОСЛЕ `check_staging_status` и ДО запуска deployer'а, который вливает PR в `main` (deployer мержит в начале стадии `deploy`). Стадии (`STAGE_TRANSITIONS`) НЕ меняются — это «под-гейт» ребра, а не отдельная стадия (триггер — то же событие «staging-deployer завершился»).
@@ -81,6 +92,42 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
деградация через минуты под трафиком, health `200 ok`, фича сломана). ORCH-021 продлевает
ответственность **ЗА** `done`: для применимого репо после терминального перехода армится
наблюдение окна `post_deploy_window_s` (~15 мин) с интервалом `post_deploy_interval_s`;
деградация фиксируется по детерминированным порогам, при подтверждении — реакция.
Механизм — **reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`, НЕ
стадия и НЕ daemon): арм в `advance_stage` в блоке `next_stage == "done"`
(`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность); тик перехватывается в
`launcher.launch_job` ДО `_spawn``stage_engine.run_post_deploy_monitor` (один опрос →
append в `series` → классификация → перепостановка с задержкой ИЛИ реакция+артефакт+`done`).
Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise): `post_deploy_applies`,
`probe_signals` (`/health` 200+`{"status":"ok"}` + доля 5xx на `/status`,`/queue`),
`classify` (HEALTHY|DEGRADED — главный предмет юнит-тестов), `decide_action`,
sentinel-state, `write_post_deploy_log`.
- **Пороги (BR-3):** `DEGRADED``≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов
health ИЛИ доля 5xx `> post_deploy_5xx_threshold`; одиночный глюк → HEALTHY (нет ложных
откатов).
- **Реакция:** self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY` (Telegram+Plane, ручной
approve; тик НИКОГДА не откатывает/рестартит прод-контейнер); не-self +
`post_deploy_auto_rollback=true` → хук `--rollback` (`0→ROLLBACK_OK`,
`1/2→ROLLBACK_FAILED`+алерт); дефолт → `ALERT_ONLY`.
- **Артефакт** `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/
`action_taken`/…) — машиночитаемо для петли уроков ORCH-8; best-effort.
- **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец `reconcile`).
- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, terminal-sync,
merge-gate, exit-коды хука (0/1/2), схема БД — НЕ меняются. Restart-safe (sentinel
`.post-deploy-state-<repo>/<wi>/` + jobs-очередь). Kill-switch
`post_deploy_monitor_enabled`, область `post_deploy_repos` (пусто → self-hosting).
Условность как ORCH-35/36/43/58.
Подробнее: [adr-0010](adr/adr-0010-post-deploy-monitor.md), детально —
`docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`.
### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано)
BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод
**без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет:
@@ -118,6 +165,13 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
`age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка канонического QG;
зелёный → `stage_engine.advance_stage(..., finished_agent=None)`; красный →
тишина (спам нотификаций структурно невозможен). `analysis` не реконсилируется.
**Skip escalated / Blocked / Needs-Input (ORCH-060):** ДО оценки гейта F-1
пропускает (молча, без advance/нотификаций) задачи, которые ждут человека —
(1) исчерпавшие лимит developer-ретраев (`developer_retry_count(task_id) >=
MAX_DEVELOPER_RETRIES`, детерминированно, без сети — закрывает bounce-петлю
ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A —
запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард
retry-count проверяется первым (дёшево, локальный SQL).
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
@@ -137,6 +191,104 @@ never-raise на единицу работы; тишина при синхрон
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
[adr-0007](adr/adr-0007-reconciler.md), детально — `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`.
### Job-reaper + проактивный реклейм merge-lease (ORCH-065 — design)
Финализация статуса job (`done`/`queued`/`failed`) выполняется ТОЛЬКО в
`launcher._monitor_agent → _finalize_job` внутри живого процесса. Смерть
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
self-restart во время deploy) оставляла строку `jobs` навсегда `running`; при
`max_concurrency=1` одна зомби-строка блокирует claim всех job → встаёт конвейер
ВСЕХ проектов (инциденты 07.06: jobs 236/239/242/254). `requeue_running_jobs()`
спасал ТОЛЬКО на старте процесса. Симметрично залипал merge-lease (ORCH-043):
реклейм был лениво-по-TTL и только при чужом `acquire`, liveness держателя по pid
не проверялся. Это последняя ручная точка автономного self-deploy (блокер ORCH-54).
ORCH-065 вводит фоновый watchdog, чтобы смерть процесса/потока на любой стадии НЕ
оставляла навсегда захваченных ресурсов:
- **Job-reaper** (`src/job_reaper.py`) — daemon-поток по образцу `reconciler`,
работает **без рестарта**. Трёхуровневая liveness: Tier-1 мёртвый `jobs.pid`
(новая колонка) после `reaper_dead_ticks` подряд тиков (анти-ложноположительность
— живой долгий агент не реапится); Tier-2 `agent_runs.exit_code` записан, а job
ещё `running` — но это окно неоднозначно (живой monitor пишет exit_code ПЕРВЫМ,
затем git push/PR/Plane-комментарии), поэтому Tier-2 реапит только после
finalization-grace `reaper_finalize_grace_s` (живой финализирующий monitor НЕ
реапится); Tier-3 backstop по потолку `reaper_max_running_s` (> max
agent_timeout+grace). Действие переиспользует контракты по принципу
**claim-before-act**: для exit0 канонический QG оценивается read-only ПЕРЕД
атомарным claim, затем claim `done` ПЕРВЫМ и только победитель claim делает
`_try_advance_stage` (advance+enqueue) — проигравший claim (поздний monitor /
стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
источник истины — канонический QG, не факт «exit0»; гейт красный или exit≠0/
неизвестно → `attempts<max`→`queued`, иначе `failed`+Telegram. Атомарный
reap-claim (`UPDATE ... WHERE id=? AND status='running'`) совместим со стартовым
`requeue_running_jobs` (restart-safe, без двойной обработки).
- **Проактивный реклейм stale/dead lease** (функции в `merge_gate.py`:
`pid_alive`, `reclaim_stale_lease`) — на старте (рядом с `requeue_running_jobs`)
и периодически из тика reaper: освобождает lease, чей держатель **мёртв** (pid
не жив) ИЛИ **просрочен** (TTL `merge_lock_timeout_s`); живой держатель в
пределах TTL — НЕ трогать (защита легитимного merge). holder-aware, never-raise,
условность как ORCH-43 (`merge_gate_repos`/self-hosting).
- **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не повторяются
(`branch_is_behind_main==False`); добавлен never-raise guard `pr_already_merged`
(читает состояние PR) — уже слит = no-op. **Консультируется самим merge-актором:**
фактический merge PR в `main` делает агент `deployer` (в начале стадии `deploy`),
поэтому wiring — в его промпте `.openclaw/agents/deployer.md`, который вызывает
`pr_already_merged` ПЕРЕД любым (повторным) merge (AC-11). Чек `check_branch_mergeable`
НЕ меняется (AC-13): он на ПЕРВОМ ребре `deploy-staging → deploy`, а риск второго
merge — на re-drive самой стадии `deploy`.
- **Схема БД:** единственное изменение — `jobs.pid INTEGER` через идемпотентный
`_ensure_column` (live-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
exit-коды хука, файл-схема lease — без изменений.
- **Наблюдаемость:** блок `reaper` в `GET /queue` (enabled, interval, last_run_ts,
reaped_total, last_reaped, lease_reclaimed_total); каждый reap/lease-reclaim →
`logger.warning`; reap→`failed` и lease-reclaim → Telegram.
- **Kill-switch'и:** `ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`,
`ORCH_REAPER_DEAD_TICKS`, `ORCH_REAPER_MAX_RUNNING_S`,
`ORCH_REAPER_FINALIZE_GRACE_S`, `ORCH_LEASE_RECLAIM_ENABLED`; `false` → строго
прежнее поведение.
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
### Осмысленная статусная модель Plane (ORCH-066 — реализовано)
Plane-доска была семантически перегружена: `In Progress` означал «человек запускает
конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно.
ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B**
(Plane-индикация: `src/plane_sync.py` + точки простановки в `src/stage_engine.py`/
`src/webhooks/plane.py`/`src/reconciler.py`) и **не трогая слой A** (`STAGE_TRANSITIONS`,
инвариант). Статус — индикация, не управление (вердикты по-прежнему из YAML-frontmatter):
```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
`[...]` = человеческий вход-триггер; остальное ставит орк.
- **6 новых логических ключей** (`to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/
`deploying`/`monitoring`) в `_PLANE_NAME_TO_KEY` (резолв по имени) + `_DEFAULT_STATES`.
`To Analyse` заменяет `In Progress` как вход-триггер (старт + resume аналитика из Needs
Input; fork «старт vs resume» по `get_task_by_plane_id`+`has_active_job_for_task` —
сохранён). Стадии: analysis→`Analysis`, review→`Code-Review` (`_STAGE_TO_STATE_KEY`).
- **Self-deploy фазы:** Phase A → `Awaiting Deploy` (разгружает `In Review`), Phase B →
`Deploying`, Phase C/terminal-sync (self) → `Monitoring after Deploy` (НЕ `Done` сразу);
post-deploy monitor (ORCH-021): HEALTHY-окно → `Done`, DEGRADED → `Blocked` (тик
по-прежнему НИКОГДА не рестартит прод — ALERT_ONLY). Не-self репо: `deploy → Done` как
сейчас (terminal-sync разводится по `post_deploy.post_deploy_applies`).
- **Fail-closed (project-relative alias-fallback):** отсутствующий новый статус в проекте
деградирует на **собственный базовый UUID того же проекта** (`to_analyse/analysis→in_progress`,
`code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`,
`monitoring→done`) — индикация откатывается к текущей, конвейер не ломается, PATCH валиден
даже при частичной конфигурации. Enduro (статусы не создаются) → строго прежнее поведение.
Усиленный паттерн ORCH-059 AC-7.
- **Reconciler:** F-2 триггер `in_progress`→`to_analyse`; Guard 2 skip-set расширен
активными ожиданиями (`awaiting_deploy`/`deploying`/`monitoring`) с **вычитанием базовых
рабочих статусов** — на enduro (алиасы схлопнуты) нулевой регресс, на orchestrator skip
реальных ожиданий (BR-13).
- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, exit-коды хука,
merge-gate, `Confirm Deploy`, механизм `Needs Input` (analyst-only), схема БД — без
изменений. Без нового kill-switch (раскат гейтится созданием Plane-статусов оператором).
Инфра-предусловие — `docs/work-items/ORCH-066/07-infra-requirements.md`.
Подробнее: `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`.
## Откаты
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
@@ -170,7 +322,7 @@ never-raise на единицу работы; тишина при синхрон
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1)
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
## Изоляция (git worktree, ORCH-2)
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
@@ -180,7 +332,7 @@ never-raise на единицу работы; тишина при синхрон
|--------|------|----------|
| GET | `/health` | health check |
| GET | `/status` | активные задачи (stage != done) |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + последние jobs |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + последние jobs |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
@@ -194,4 +346,4 @@ never-raise на единицу работы; тишина при синхрон
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile).*
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).*

View File

@@ -12,6 +12,16 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0005 | Контейнеры бегут под uid:gid хоста (1000:1000) | accepted | 2026-06-06 | ORCH-040 |
| adr-0006 | Merge-gate (догон main + re-test + сериализация слияний) | proposed | 2026-06-06 | ORCH-043 |
| adr-0007 | Reconciler застрявших стадий (sweeper потерянных webhook) | accepted | 2026-06-06 | ORCH-053 |
| adr-0007 | Исполняемый самодеплой стадии `deploy` (файл adr-0007-executable-self-deploy) | accepted | 2026-06-06 | ORCH-036 |
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 |
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
> свободный номер (текущий максимум — `0011`).
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

View File

@@ -61,6 +61,14 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти
(`reconcile_plane_enabled` гасит только F-2); reconciler не рестартит/не роняет
прод-контейнер. БД-схема и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются.
## Уточнения
- **ORCH-060** (`docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`):
F-1 (`_reconcile_gate_task`) приобретает два пред-гарда ДО оценки гейта —
пропускает escalated (`developer_retry_count ≥ MAX_DEVELOPER_RETRIES`,
детерминированно) и Blocked/Needs-Input (Вариант A, Plane API, без миграции)
задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise,
тишина при пропуске).
## Связи
adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный
гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра

View File

@@ -0,0 +1,56 @@
# adr-0009: Толерантность staging-вердикта к заведомо инфраструктурным FAIL
- **Статус:** accepted
- **Дата:** 2026-06-07
- **Задача:** ORCH-061
- **Детально:** `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`
## Контекст
Self-hosting `orchestrator` зацикливался на `deploy-staging`: `staging_check.py`
давал 2 ложных FAIL (C9a — ветка в sandbox, C9b — analyst-job в очереди), вызванных
отсутствием sandbox-настроек (bot-аккаунты не члены SANDBOX-проекта), а не регрессом
кода. `staging_check.py` делал `sys.exit(1)` при любом FAIL → deployer писал
`staging_status: FAILED``check_staging_status` FAILED → откат `deploy-staging →
development` → петля (жгла developer-ретраи и кредиты). Прод-деплой орка приходилось
доводить вручную — блокер автономного внедрения (ORCH-54).
## Решение
Классифицировать проверки staging-suite на **REAL** (pipeline) и **SANDBOX_INFRA**
(заведомо инфраструктурные, узкий allowlist `{C9a, C9b}`) и сделать вердикт
толерантным к инфра-FAIL, сохранив fail-closed для реальных проверок:
- Новый leaf-модуль `src/staging_verdict.py` (pure, never-raise, stdlib):
`classify_check(label)` + `compute_staging_verdict(items, infra_tolerant)`.
Правило: упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и
толерантность вкл → SUCCESS/exit0 (waived); толерантность выкл → legacy strict
(любой FAIL → FAILED).
- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через
`staging_verdict`, печатает `INFRA-WAIVED` при вайвере (наблюдаемость).
- Kill-switch `staging_infra_tolerance_enabled` (env
`ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `True`; в `.env.staging`).
- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр
`QG_CHECKS`**без изменений**; новый QG-чек не вводится. Условность ORCH-35
сохранена (не-self → no-op N/A).
- Инвариант FR-3: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`)
не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом
(launcher уже не откатывает; добавлена observability-строка).
## Альтернативы
- Только починить sandbox-инфру (направление а) — хрупко, не структурно, вне
автономной досягаемости таска; оставлено как опциональное hardening.
- «Зелёный по умолчанию» при недоступности проверок — запрещён (fail-closed).
- Новый QG-чек / структурный артефакт `15-staging-log.md` — избыточно, меняло бы
контракты/реестр; толерантность размещена в suite до артефакта.
## Последствия
- Петля устранена; страховка цела (реальный регресс → FAILED → откат).
- Чистая вердикт-логика юнит-тестируема без live staging/docker.
- Контракты гейтов/стадий/вердиктов/реестра и схема БД неизменны.
- Риск: узкое окно — реальный регресс именно в создании ветки/постановке
analyst-job может быть заваивен; митигировано allowlist'ом `{C9a,C9b}` + условием
«все REAL (вкл. C7/C8) зелёные» + INFRA-WAIVED-логом. Разблокирует ORCH-54.
## Связи
adr-0003 (условный staging-гейт — база `is_self_hosting_repo` / `check_staging_status`),
adr-0006 (merge-gate), adr-0007 (исполняемый self-deploy), adr-0008 (провенанс
staging-образа). Блокирует ORCH-54.

View File

@@ -0,0 +1,85 @@
# adr-0010: Post-deploy мониторинг прода + реакция на деградацию
- **Статус:** proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`
- **Дата:** 2026-06-07
- **Задача:** ORCH-021
- **Метка:** `arch:major-change` (новая под-компонента + новый reserved-agent job-kind)
- **Детальный ADR:** `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`
## Контекст
Конвейер заканчивается на `deploy → done`: `check_deploy_status` видит
`deploy_status: SUCCESS` → terminal-sync (Plane → Done, release merge-lease), и
оркестратор **забывает про прод**. «Успех» сегодня = health-check в момент рестарта
(~60с окно в `orchestrator-deploy-hook.sh`). Класс инцидентов «зелёный деплой, красный
прод» (прецедент **ET-8**): деградация проявляется через минуты под боевым трафиком,
health отвечает `200 ok`, фича сломана. Для self-hosting опасно вдвойне — сломанный
прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса.
## Решение
Продлить ответственность конвейера **ЗА** `done`: после терминального перехода для
применимого репо армится пост-деплой наблюдение окна `post_deploy_window_s` (дефолт
~15 мин) с интервалом `post_deploy_interval_s`; деградация фиксируется по
**детерминированным порогам**, при подтверждении выполняется реакция.
**Механизм — reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`,
ORCH-36), НЕ отдельная стадия и НЕ daemon-поток:
- **Арм:** в `stage_engine.advance_stage`, в блоке `next_stage == "done"`, при
`post_deploy.post_deploy_applies(repo)``post_deploy.arm_monitor(...)` (sentinel
`armed` = идемпотентность, первый job через `enqueue_job(available_at_delay_s=...)`).
- **Тик:** `launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО
`_spawn``stage_engine.run_post_deploy_monitor(job)`: один опрос сигналов, append в
персистентный `series`, классификация; HEALTHY и окно не истекло → перепостановка с
задержкой; иначе → реакция + артефакт + `mark_done`.
- **Чистая логика — новый leaf-модуль `src/post_deploy.py`** (never-raise, по образцу
`self_deploy.py`/`staging_verdict.py`): `post_deploy_applies`, `probe_signals`
(опрос `/health` + доля 5xx на `/status`,`/queue`), `classify` (HEALTHY|DEGRADED —
главный предмет юнит-тестов), `decide_action` (NONE|ROLLBACK|ALERT_ONLY с учётом
self-hosting), sentinel-state хелперы, `write_post_deploy_log`.
**Сигналы и пороги (детерминированно, AC-3…AC-6):** `DEGRADED``≥
post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health ИЛИ доля 5xx на окне `>
post_deploy_5xx_threshold`. Одиночный глюк < порога → HEALTHY (нет ложных откатов).
**Реакция (BR-4/BR-5):**
- **Self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY`:** громкий Telegram + Plane,
запрос ручного approve отката. Тик НИКОГДА не откатывает/рестартит прод-контейнер
(структурный инвариант). Откат прод-орка, если оператор решит, — только detached
host-процесс (`self_deploy.initiate_deploy`), вне тика (MVP).
- **Не-self + `post_deploy_auto_rollback=True`:** хук `--rollback` с прод-env; exit
`0 → ROLLBACK_OK`, `1/2 → ROLLBACK_FAILED` + громкий алерт.
- Дефолт (`auto_rollback=False`) → `ALERT_ONLY`.
**Артефакт `16-post-deploy-log.md`** (новый) с YAML-frontmatter (`post_deploy_status`,
`action_taken`, `window_s`, `checks_total/failed`) — машиночитаемо для петли уроков
ORCH-8; best-effort. **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец
`reconcile.status()`).
## Альтернативы
- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия опросов в памяти не
restart-safe (а деплой орка = рестарт); restart-safe-вариант требует тех же sentinel,
reserved-agent проще и уже имеет проверенную jobs+sentinel машинерию.
- **Отдельная пост-deploy стадия + QG** — отклонён: меняет `STAGE_TRANSITIONS`/
`QG_CHECKS`, ломает семантику терминального `done`; наблюдение принципиально ПОСЛЕ
`done`.
- **Авто-rollback прод-орка из тика** — отклонён (self-hosting safety): групповой риск;
контейнер не откатит себя надёжно. Self → alert + ручной approve (как ORCH-54).
- **Колонка в `tasks`** — отклонён: миграция на проде; sentinel-файлы restart-safe
(как ORCH-36/53/58).
## Последствия
- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация =
сигнал для ORCH-8.
- Реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`), контракт `check_deploy_status`,
terminal-sync, merge-gate, exit-code-контракт хука, схема БД — **не меняются**.
- Дефолты безопасны: kill-switch on, auto-rollback off, self только alert.
- Ограничение: монитор self бежит внутри наблюдаемого прода — полностью wedged
контейнер = пропущенный тик/алерт (known MVP gap; внешний watchdog — follow-up).
- Self-hosting: тик не рестартит/не роняет прод-контейнер; kill-switch
`post_deploy_monitor_enabled` обязателен; поэтапный раскат через `post_deploy_repos`.
## Связи
adr-0007-executable-self-deploy (ORCH-36 — sentinel/detached-host/finalizer образец,
`map_exit_code_to_status`), adr-0007-reconciler (ORCH-53 — daemon/`status()` образец,
отклонён как основной механизм), adr-0006 (merge-gate — условность/флаги раската),
adr-0003 (staging-gate — образец условности), adr-0008 (provenance — `.deploy-prev-image`/
хук-откат). Прецедент ET-8. Будущее: ORCH-8 (петля уроков), ORCH-54 (полный авто).

View File

@@ -0,0 +1,82 @@
# adr-0011: Job-reaper + проактивный реклейм merge-lease
| | |
|---|---|
| Статус | accepted |
| Дата | 2026-06-07 |
| Источник | ORCH-065 (BUG P0, блокер ORCH-54) |
| Детально | `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md` |
## Контекст
Единый инстанс с общей БД и очередью (`jobs`, `max_concurrency=1` для
self-hosting). Финализация статуса job (`done`/`queued`/`failed`) происходит
ТОЛЬКО в `launcher._monitor_agent → _finalize_job` внутри живого процесса. Смерть
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
self-restart во время deploy) оставляет строку `jobs` навсегда `running`. При
`max_concurrency=1` одна такая зомби-строка блокирует claim всех job →
**встаёт конвейер всех проектов**. Единственная защита — `requeue_running_jobs()`
— работает ТОЛЬКО на старте процесса. Симметрично: merge-lease (ORCH-043,
файл `.merge-lease-<repo>.json`) реклеймится лишь лениво по TTL при чужом
`acquire`; liveness держателя по pid не проверяется → залипший lease блокирует
чужие merge. Это последняя ручная точка автономного self-deploy (блокер ORCH-54);
доказанные инциденты 07.06 — jobs 236/239/242/254.
## Решение
1. **Job-reaper** — новый daemon-поток `src/job_reaper.py` (каркас `reconciler`:
never-raise, `_stop`-Event, старт/стоп в `lifespan`, снимок в `/queue`,
kill-switch). Работает **без рестарта** процесса. Liveness — трёхуровневая:
Tier-1 мёртвый `jobs.pid` (новая колонка) после `reaper_dead_ticks` подряд
тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running` — но только
после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой
monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии, поэтому
живой финализирующий monitor НЕ реапится); Tier-3 backstop по потолку
`reaper_max_running_s`. Действие — **claim-before-act**: для exit0 канонический
QG оценивается read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и
только победитель claim выполняет `_try_advance_stage` (advance+enqueue) —
проигравший не делает побочных эффектов (источник истины — QG, не «exit0»);
гейт красный или exit≠0 / неизвестно → `attempts<max``queued`, иначе
`failed`+Telegram. Атомарный reap-claim (`UPDATE ... WHERE id=? AND
status='running'` + `rowcount`, как `claim_next_job`) исключает двойную
обработку (совместимость со стартовым `requeue_running_jobs`).
2. **Проактивный реклейм stale/dead lease** — функции в `merge_gate.py`
(`pid_alive`, `reclaim_stale_lease`), вызываемые на старте (рядом с
`requeue_running_jobs`) и периодически из тика reaper. Освобождение, если
держатель **мёртв** (pid не жив) ИЛИ **просрочен** (TTL); живой держатель в
пределах TTL — НЕ трогать. holder-aware, never-raise, условность как ORCH-43.
3. **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не
повторяются (`branch_is_behind_main==False`); добавлен детерминированный
never-raise guard `pr_already_merged` (читает состояние PR), консультируемый
перед повторным merge → уже слит = no-op.
4. **Схема БД**`jobs.pid INTEGER` через идемпотентный `_ensure_column`
(паттерн live-safe миграции). Больше ничего не меняется.
Kill-switch'и (`ORCH_*`): `reaper_enabled`, `reaper_interval_s`,
`reaper_dead_ticks`, `reaper_max_running_s`, `reaper_finalize_grace_s`,
`lease_reclaim_enabled`; переиспользуются `merge_lock_timeout_s`,
`merge_gate_repos`. `false` → строго прежнее поведение.
## Альтернативы
- Reaper внутри reconciler — отвергнуто (смешение stage- и jobs-уровней, общий
kill-switch, хуже изоляция).
- Только эвристика `agent_runs` без `jobs.pid` — отвергнуто как основной механизм
(не ловит зомби, чей monitor умер до записи exit_code); оставлена как Tier-2/3.
- БД-lock / внешний брокер очередей — вне объёма (single-node SQLite).
- Форс `done` по факту exit0 — отвергнуто; выбран gate-driven advance.
## Последствия
- (+) Зомби-job и залипший lease самовосстанавливаются без рестарта и без
оператора; очередь общего инстанса не встаёт; снят технический блокер ORCH-54.
- (+) Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
exit-коды хука); одна колонка через проверенный idempotent-паттерн.
- () pid-liveness валиден в предположении одного pid-namespace (агент —
дочерний процесс оркестратора); закрыто backstop'ом по времени и TTL.
- () streak-счётчик in-memory (сброс на рестарте; рестарт покрыт
`requeue_running_jobs`).
## Связи
- Базируется: adr-0002 (очередь), adr-0006 (merge-gate), adr-0007 (reconciler /
self-deploy).
- Разблокирует: ORCH-54.

View File

@@ -326,6 +326,7 @@ webhook (plane/gitea) background thread (queue_worker)
| `status` | `queued``running``done` \| `failed` |
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
| `run_id` | FK на `agent_runs.id` после старта |
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |
| `task_content` | ТЗ, которое пишется в task-файл агента |
| `error` | последняя ошибка |
@@ -343,6 +344,36 @@ status='queued'` и проверяет `rowcount`. При гонке двух т
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
### Job-reaper (ORCH-065, рестарт НЕ требуется)
`requeue_running_jobs()` спасает ТОЛЬКО на старте процесса. Зомби-job, возникший
**без** рестарта (умер monitor-поток/дочерний процесс, а сервис жив), оставался
`running` навсегда и при `max_concurrency=1` блокировал всю очередь. Фоновый
daemon-поток `src/job_reaper.py` (каркас `reconciler`) периодически
(`reaper_interval_s`) сканирует `running`-jobs и реапит «мёртвые»:
- **Tier-1** — `jobs.pid` мёртв (`os.kill(pid,0)``ProcessLookupError`) на
протяжении `reaper_dead_ticks` подряд тиков (анти-ложноположительность);
- **Tier-2** — у `agent_runs[run_id]` записан `exit_code`, а `jobs.status` ещё
`running`. Окно неоднозначно: живой monitor пишет `exit_code` ПЕРВЫМ, затем
git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом
`_finalize_job`; pid агента к этому моменту мёртв в обоих случаях. Поэтому
Tier-2 реапит только после finalization-grace `reaper_finalize_grace_s`
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится;
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,
как `claim_next_job`) → совместим со стартовым `requeue_running_jobs` без двойной
обработки. Действие — **claim-before-act**: для exit0 канонический QG оценивается
read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и только победитель
claim делает `_try_advance_stage` (advance+enqueue) — проигравший (поздний monitor
/ стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
источник истины — QG, не «exit0»; гейт красный или exit≠0/неизвестно →
`attempts<max``queued`, иначе `failed`+Telegram. Тот же поток на старте и
периодически делает проактивный реклейм stale/dead merge-lease (`merge_gate.py`:
`pid_alive`/`reclaim_stale_lease`). never-raise; kill-switch `ORCH_REAPER_ENABLED`
/ `ORCH_LEASE_RECLAIM_ENABLED`; снимок в `GET /queue` (блок `reaper`). Подробнее —
adr-0011.
### Конфиг
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.

View File

@@ -0,0 +1,78 @@
# Lessons Learned — 2026-06-07: замыкание автономности self-deploy (5 задач в прод)
## Итог
За одну сессию закрыты в прод **5 задач**, завершающих автономный self-deploy эпика ORCH-54:
| Задача | Что | Прод-коммит |
|--------|-----|-------------|
| ORCH-58 | provenance retag-guard (свежесть staging-образа перед BUILD-ONCE) | 094b5e2 |
| ORCH-60 | reconciler не трогает escalated/Blocked/Needs-Input | d4c6cc0 |
| ORCH-61 | фикс петли deploy-staging (staging_verdict: waive sandbox-infra FAILs C9a/C9b) | e18947d |
| ORCH-21 | post-deploy мониторинг прода + auto-rollback (self-hosting=alert-only) | f85e449 |
| ORCH-65 | job-reaper + stale merge-lease reclaim + idempotent merge | bb03350 |
**Главное:** после ORCH-60/61 конвейер впервые провёз задачи (ORCH-21/65) через deploy-staging
**автономно** без отката; после ORCH-65 (job-reaper в проде) зомби-job и зависшие merge-lease
лечатся сами. Последняя ручная точка автономного деплоя закрыта.
---
## Класс багов: «процесс умер — ресурс захвачен навсегда» (ORCH-65)
Три связанных отказа, все воспроизвелись на ORCH-58/60/61/21:
- **zombie jobs:** агент завершился/умер, строка jobs осталась running. requeue_running_jobs()
спасает только на старте процесса; зомби без рестарта не лечился → при concurrency=1 встаёт
конвейер ВСЕХ проектов. (jobs 236/239/242/254/265 — все зомби за сессию.)
- **stale merge-lease:** merge-gate берёт .merge-lease-<repo>.json, делает rebase+re-test green,
а на финальном merge процесс умирает с зажатым lease → merge не докатывается.
- **неидемпотентный merge:** re-drive повторно пытается слить уже слитый PR.
Фикс: фоновый job_reaper (паттерн reconciler, dead_ticks streak + мёртвый pid + exit_code,
атомарный reap-claim, never-raise, kill-switch, снимок в /queue) + проактивный lease-reclaim
по pid + guard pr_already_merged ПЕРЕД merge.
## Петля deploy-staging (ORCH-61) — ДВЕ причины
1. ложный check_staging_status FAILED: staging_check падает на C9a/C9b (sandbox e2e branch +
analyst-job-in-queue), т.к. bot-токены SANDBOX-проекта не настроены — НЕ регресс кода.
2. no-changes для action-стадий (деплой = рестарт/retag, не правка → коммитить нечего).
Фикс: staging_verdict waive sandbox-infra-only FAILs.
## Инфра-каскад от переполненного диска (инцидент дня)
- Частые build-once/--build-staging пересборки за день забили docker build cache до 11 ГБ →
диск 100% → CI red (No space left).
- ДАЖЕ после чистки диска Gitea осталась в сломанном состоянии: внутренняя queue
(/data/gitea/queues/common/*.log) залипла → post-receive hook 500 → actions tasks НЕ
создаются, CI не триггерится вовсе (статус пустой, не failure). runner при этом online+idle.
- Лечение: docker builder prune -af + рестарт Gitea (queue распускается → CI ожил).
---
## Уроки
1. **Self-hosting safety (сквозной принцип):** прод-орк обслуживает ВСЕ проекты. Нельзя авто-
откатывать/рестартить self в рамках задачи; нельзя пушить main. ORCH-21 post-deploy для
self-hosting = alert-only, авто-rollback только для не-self репо.
2. **TDD без доводки (повтор ORCH-58 и ORCH-65 v1):** тесты есть, реализация/wiring не
подключены к боевому пути → мёртвый код + врущая дока. Reviewer обязан грепать вызовы из
прод-кода, не только наличие функции.
3. **Concurrency-баги ловятся итеративно:** ORCH-65 3 прохода reviewer (мёртвый guard → race
condition side-effects-before-claim → approve) — каждый раз НОВЫЙ реальный дефект, не
зацикливание. Atomic-claim ДО side-effects — обязательное правило.
4. **При красном CI + зелёных локальных тестах — ПЕРВЫМ делом df -h / и docker system df**,
не копаться в коде. После disk-full обязателен рестарт Gitea (queue залипает).
5. **Bootstrap-разрыв:** задача про автономность деплоя не может задеплоить себя автономно,
пока её механизм не в проде. Последний прод-деплой каждого такого фикса — вручную.
6. **Перед прод-retag (build-once SOURCE_IMAGE=staging):** проверить revision-label staging-
образа == целевой main HEAD, иначе guard fail-closed (by design). Если != → пересобрать
--build-staging GIT_SHA=<main HEAD>.
## Ручная доводка прод-deploy (схема до ORCH-65 в проде)
cancel zombie job → park task In Progress → merge PR (Gitea pulls/{n}/merge Do=merge, CI green)
→ --build-staging GIT_SHA=<main HEAD> (проставит label) → rollback-снимок → --deploy с
EXPECTED_REVISION=<sha> (guard сверит → retag → health 200) → Plane Done + UPDATE tasks stage=done.
## Follow-up (Backlog)
- ORCH-62: авто-prune docker build cache (cron/daemon.json defaultKeepStorage).
- ORCH-63: мониторинг диска mva154 + алерт >85%.
- ORCH-64: починить NTP/часы mva154 (ушли ~+3ч от UTC).
## Осталось в эпике ORCH-54
ORCH-22 (security-гейт), ORCH-59 (Confirm Deploy статус), ORCH-23 (budget circuit-breaker),
P2: ORCH-57, ORCH-51.

View File

@@ -12,7 +12,9 @@
| B | ACCESS | Plane sandbox (R), Gitea sandbox (R+push), реестр проектов |
| C | E2E | Создать задачу → триггер конвейера → ветка + коммент → cleanup |
Exit code: **0** = все PASS, **non-zero** = есть FAIL.
Exit code: **0** = advance (все REAL-проверки PASS), **1** = rollback (есть REAL-FAIL).
С ORCH-061 exit 0 может включать *waived* sandbox-infra FAIL (C9a/C9b) — см.
[«Толерантность к sandbox-infra (ORCH-061)»](#толерантность-к-sandbox-infra-orch-061).
---
@@ -85,6 +87,56 @@ B6 «Registry: sandbox present, prod ET/ORCH absent» подтверждает
---
## Толерантность к sandbox-infra (ORCH-061)
**Проблема.** Self-hosting `orchestrator` зацикливался на `deploy-staging → development`:
прежде скрипт давал exit 1 при **любом** FAIL, поэтому две чисто инфраструктурные
проверки — **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job
аналитика не встал в очередь staging) — приводили к `staging_status: FAILED`
откат → цикл. Корень: SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane,
поэтому шаги 6+ конвейера в песочнице недостижимы. Это **не** регресс конвейера.
**Решение.** Проверки классифицируются на две категории (`src/staging_verdict.py`):
| Категория | Что входит | Поведение |
|-----------|-----------|-----------|
| `REAL` | все проверки конвейера (A*, B*, C7, C8) | **fail-closed** — любой FAIL = rollback |
| `SANDBOX_INFRA` | строго allowlist `{C9a, C9b}` | **waivable** — FAIL терпится, если все REAL зелёные |
Вердикт сворачивается в `compute_staging_verdict(items, infra_tolerant)`:
- любой REAL-FAIL → `FAILED` / exit 1 (страховка сохраняется при ЛЮБОМ значении флага);
- упали **только** C9a/C9b и толерантность включена → `SUCCESS` / exit 0,
упавшие метки попадают в `waived` (наблюдаемость, печатается строкой `INFRA-WAIVED:`);
- упали только C9a/C9b, толерантность выключена → `FAILED` / exit 1 (legacy-строгий);
- любая внутренняя ошибка вердикта → `FAILED` / exit 1 (никогда не ложный green).
Blast-radius waiver-а ровно две allowlist-метки; всё неизвестное классифицируется
как `REAL` (fail-closed).
### Kill-switch и `--strict`
| Управление | Эффект |
|-----------|--------|
| env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (default `true`) | глобальный флаг; `false` → строгий режим (1:1 до ORCH-061) |
| CLI `--strict` | форсит строгий режим для одного запуска, игнорируя env |
Флаг живёт в `.env.staging` (staging-инстанс). `--strict` имеет приоритет над env.
### Что печатает скрипт
В конце прогона `summary()` показывает разбивку REAL/SANDBOX_INFRA, затем:
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox; C9b Analyst job enqueued ...
VERDICT: SUCCESS (infra-waived): ['C9a …', 'C9b …'] are known sandbox-infra checks; all real checks green
```
Контракт `staging_status: SUCCESS|FAILED` во frontmatter **не меняется**
толерантность применяется в скрипте ДО записи артефакта деплоером.
---
## Режимы (`--mode`)
| Режим | Описание | Скорость |

View File

@@ -0,0 +1,7 @@
# Business Request: [★ высокий] Post-deploy мониторинг прода + авто-rollback при деградации
Work Item ID: ORCH-021
## Description
TBD

View File

@@ -0,0 +1,88 @@
# BRD — ORCH-021: Post-deploy мониторинг прода + авто-rollback при деградации
Work Item: ORCH-021
Приоритет: высокий (★)
Источник: предложение Стрим, одобрено Славой (2026-06-04)
Стадия: analysis
## 1. Проблема (Why)
Сейчас конвейер заканчивается на `deploy → done`: как только `check_deploy_status`
видит `deploy_status: SUCCESS`, задача закрывается и оркестратор **забывает про прод**.
«Успех» деплоя сегодня означает только то, что health-check в момент рестарта
прошёл (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60 секунд.
**Прямой урок ET-8:** деплой отрапортовал SUCCESS, а на проде фича не работала.
Класс инцидентов — «зелёный деплой, красный прод»:
- деградация проявляется через минуты, а не в первые 60с (прогрев кэшей, фоновые
миграции, отложенные запросы, утечки, рост 5xx под реальным трафиком);
- health-эндпоинт отвечает `200 ok`, но ключевая функциональность сломана;
- регресс виден только под боевым трафиком, которого нет в момент рестарта.
После закрытия задачи никакого пригляда за продом нет — деградацию замечает человек
постфактум. Для self-hosting это особенно опасно: сломанный прод-орк (8500) обслуживает
ВСЕ проекты (enduro-trails) из общего инстанса.
## 2. Цель (What)
Продлить ответственность конвейера за прод **после** `deploy → done`: в течение
заданного окна наблюдать ключевые сигналы здоровья прода и при доказанной деградации
выполнить реакцию (откат на предыдущий образ или громкий алерт с запросом ручного
отката). Закрыть класс «зелёный деплой, красный прод».
Механизм частичного отката уже есть: `do_rollback()` и режим `--rollback` в
`scripts/orchestrator-deploy-hook.sh` умеют вернуть предыдущий образ из
`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`), который сохраняется при каждом деплое.
Задача — построить **наблюдение поверх** этого и привязать решение к измеримым порогам.
## 3. Заинтересованные стороны
- **Owner (Слава)** — принимает риск авто-отката прода; получает алерты.
- **Стрим** — инициатор; потребитель сигнала деградации для петли уроков (ORCH-8).
- **Другие проекты (enduro-trails)** — косвенно: устойчивость общего инстанса.
## 4. Бизнес-требования
| # | Требование | Приоритет |
|---|------------|-----------|
| BR-1 | После `deploy → done` прод наблюдается в течение конфигурируемого окна (дефолт ~15 мин), а не забывается. | Must |
| BR-2 | Деградация определяется по **детерминированным измеримым сигналам**: периодический `/health` (HTTP 200 + `{"status":"ok"}`) и доля HTTP 5xx на ключевых эндпоинтах (`/status`, `/queue`). | Must |
| BR-3 | Деградация фиксируется только по **порогам** (N последовательных провалов / окно), а не по разовому сетевому глюку — чтобы не было ложных откатов. | Must |
| BR-4 | При подтверждённой деградации система выполняет реакцию: **авто-rollback** на `.deploy-prev-image-prod` (через существующий хук `--rollback`) **либо** громкий алерт с запросом ручного отката — в зависимости от политики репозитория. | Must |
| BR-5 | **Self-hosting safety:** для самого `orchestrator` авто-откат прода = рестарт инструмента, обслуживающего все проекты. По умолчанию для self-hosting реакция — **алерт + ручной approve отката** (по образцу deploy Phase A/B), НЕ автоматический откат. Для не-self репозиториев допустим авто-откат. | Must |
| BR-6 | Любой исход (наблюдение начато, деградация, откат, откат-провал, окно завершилось чисто) уведомляется в Telegram и комментарием в Plane; результат наблюдения фиксируется артефактом. | Must |
| BR-7 | Мониторинг — **restart-safe**: рестарт оркестратора (в т.ч. сам деплой) не теряет и не задваивает наблюдение. Идемпотентность по образцу reconciler / deploy-finalizer. | Must |
| BR-8 | Глобальный kill-switch (env-флаг) и список репозиториев, на которые распространяется фича (по образцу `merge_gate_enabled` / `image_freshness_enabled` / `self_deploy_repos`). Выключенный флаг = прежнее поведение (наблюдения нет). | Must |
| BR-9 | Наблюдаемость: текущее состояние пост-деплой наблюдения отражается в `GET /queue` (по образцу блока `reconcile`). | Should |
| BR-10 | Сигнал деградации пригоден для будущей петли уроков (ORCH-8): фиксируется в артефакте/логе в машиночитаемом виде. | Should |
| BR-11 | Доменный smoke результата фичи (проверка, что конкретная фича реально работает) — желателен, но выносится в follow-up; MVP ограничивается health + 5xx. | Could |
## 5. Вне рамок (Out of scope)
- Полноценная система метрик/APM (Prometheus, дашборды) — фича опирается на уже
существующие HTTP-эндпоинты, не вводит сбор метрик.
- Универсальный доменный smoke для произвольной фичи (BR-11 — follow-up).
- Полностью автоматический откат прод-орка без участия человека (противоречит
self-hosting safety; отдельная задача при наборе доверия, аналогично ORCH-54 для deploy).
- Изменение момента вердикта `deploy_status` / контракта `check_deploy_status`
(наблюдение происходит ПОСЛЕ `done`, не заменяет deploy-gate).
## 6. Связи
- **ET-8** — прецедент «deploy SUCCESS, прод не работает». Обоснование задачи.
- **ORCH-36** (`docs/architecture/adr/adr-0007-executable-self-deploy.md`) — Phase A/B/C
исполняемого самодеплоя; пост-деплой наблюдение продлевает ответственность ЗА `done`,
переиспользует sentinel-паттерн и detached-host-процесс для self-rollback.
- **ORCH-53** (`src/reconciler.py`) — каноничный паттерн фонового daemon-потока
(watchdog), запускаемого в `main.lifespan`; образец для пост-деплой наблюдателя.
- **ORCH-58** — `.deploy-prev-image` и хук-механика отката, на которые опирается реакция.
- **ORCH-8** — деградация прода = сигнал для петли уроков (BR-10).
- **ORCH-12** — фича может оформиться как пост-deploy стадия ИЛИ как watchdog (решение
архитектора, см. §7).
## 7. Открытые архитектурные вопросы (для архитектора, НЕ решаются в анализе)
1. **Где живёт наблюдение:** отдельная пост-deploy стадия конвейера vs фоновый
watchdog-daemon (по образцу `reconciler`) vs reserved-agent job (по образцу
`deploy-finalizer`). Анализ задаёт требования (BR-1, BR-7), выбор механизма — за архитектором.
2. **Механизм self-rollback для self-hosting:** откат прод-орка требует detached
host-процесса (контейнер не может надёжно откатить себя, умирая) — переиспользовать
ли `self_deploy.initiate_deploy` / хук `--rollback`.
3. Точные пороги и веса сигналов (BR-3) — анализ предлагает дефолты (см. AC), архитектор
фиксирует реализацию.

View File

@@ -0,0 +1,165 @@
# ТЗ — ORCH-021: Post-deploy мониторинг прода + авто-rollback
Work Item: ORCH-021
Стадия: analysis → (architecture)
> Документ описывает ТРЕБОВАНИЯ к изменениям и НАЗЫВАЕТ задействованные модули.
> Выбор механизма (стадия vs watchdog vs reserved-agent) и точная реализация —
> зона архитектора (см. BRD §7). Здесь фиксируется, ЧТО должно измениться и КАКИЕ
> контракты НЕЛЬЗЯ ломать.
## 1. Контекст в коде (как есть сейчас)
- Конвейер заканчивается в `src/stages.py`: `deploy → done`, gate `check_deploy_status`.
Терминальный переход `deploy → done` исполняется в `src/stage_engine.py::advance_stage`
(блок «Terminal sync», `set_issue_done`, release merge-lease). После этого ничего
не наблюдает за продом.
- `scripts/orchestrator-deploy-hook.sh` уже умеет:
- `health_check(max_attempts, sleep, label)` — опрос `http://localhost:$TARGET_PORT/health`
с проверкой `"status":"ok"`;
- `do_rollback()` — retag `PREV_IMAGE_FILE``TARGET_IMAGE` + рестарт + пост-rollback
health-check; коды возврата 0 (ок) / 1 (нет prev-образа) / 2 (rollback тоже упал);
- режим `--rollback` (ручной откат);
- при обычном деплое сохраняет `PREV_IMG` в `PREV_IMAGE_FILE`
(`.deploy-prev-image-prod` для прода, см. `settings.deploy_prod_prev_image_file`).
- Self-deploy прода идёт через detached host-процесс: `src/self_deploy.py`
(`build_deploy_command`, `initiate_deploy`, sentinel-маркеры под
`.deploy-state-<repo>/<wi>/`, `read_result`, `map_exit_code_to_status`).
- Фоновый daemon-паттерн: `src/reconciler.py` (`threading.Thread(daemon=True)` +
`threading.Event`, старт/стоп в `src/main.py::lifespan` после `worker.start()` /
перед `worker.stop()`, `status()` в `GET /queue`).
- Reserved-agent (детерминированный no-LLM job) паттерн: `deploy-finalizer`
перехват в `src/agents/launcher.py::launch_job` ДО `_spawn`, исполнение
`stage_engine.run_deploy_finalizer`, отложенная постановка через
`enqueue_job(..., available_at_delay_s=...)`.
- Условность self-hosting: `src/qg/checks.py::is_self_hosting_repo`,
`src/self_deploy.py::self_deploy_applies` (флаг + CSV-репо; пусто → только `orchestrator`).
- Наблюдаемые эндпоинты прода (`src/main.py`): `GET /health`, `GET /status`, `GET /queue`.
- API БД: `src/db.py::enqueue_job` (с `available_at_delay_s`), `get_db`,
`update_task_stage`, `get_active_tasks_for_reconcile`.
## 2. Требуемые изменения
### 2.1. Новый leaf-модуль чистой логики наблюдения — `src/post_deploy.py` (новый)
Контракт **never-raise** (по образцу `self_deploy.py` / `staging_verdict.py`).
Чистые, юнит-тестируемые функции:
- **Опрос сигналов:** функция, опрашивающая `/health` и ключевые эндпоинты
(`/status`, `/queue`) прод-инстанса (base-url из config), возвращающая структуру
с результатами (код ответа, ok-флаг, доля 5xx). Сеть/таймаут → консервативный
результат, не исключение.
- **Классификация деградации** (чистая, без сети): на вход — серия результатов
опросов; на выход — вердикт `HEALTHY | DEGRADED` по порогам (BR-3):
`≥ post_deploy_fail_threshold` последовательных провалов health ИЛИ доля 5xx
выше `post_deploy_5xx_threshold` на окне. Эта функция — основной предмет
юнит-тестов (детерминированная, как `compute_staging_verdict` в ORCH-061).
- **Решение о реакции** (чистая): по `(repo, вердикт, политика)` → одно из
`NONE | ROLLBACK | ALERT_ONLY`, с учётом self-hosting (BR-5).
- **Запись артефакта** результата наблюдения (см. §2.5), best-effort.
- Условность: хелпер `post_deploy_applies(repo)` (флаг + CSV-репо, пусто →
только self-hosting), по образцу `self_deploy_applies` / `_merge_gate_applies`.
### 2.2. Оркестрация наблюдения (механизм — выбор архитектора)
Требования к механизму (независимо от выбора стадия/watchdog/reserved-agent):
- запускается ПОСЛЕ перехода `deploy → done` для применимого репозитория (BR-1);
- наблюдает окно `post_deploy_window_s` с интервалом `post_deploy_interval_s`;
- **restart-safe и идемпотентен** (BR-7): состояние наблюдения — в sentinel-файлах
(по образцу `.deploy-state-<repo>/<wi>/`, напр. маркеры `monitor-started` /
`monitor-done`) ИЛИ через отложенные `enqueue_job(available_at_delay_s=...)`;
повторный старт не задваивает наблюдение и не теряет его при рестарте;
- по итогу вызывает «Решение о реакции» из `src/post_deploy.py` и исполняет реакцию (§2.3).
Кандидатные точки интеграции (на выбор архитектора, см. BRD §7):
- хук в `stage_engine.advance_stage` в блоке `next_stage == "done"` — арм наблюдения;
- reserved-agent `post-deploy-monitor` (расширение `launcher.launch_job` ДО `_spawn`,
как `deploy-finalizer`), с само-перепостановкой через `available_at_delay_s`;
- отдельный daemon-поток `PostDeployWatcher` (как `Reconciler`), старт/стоп в `main.lifespan`.
### 2.3. Реакция на деградацию
- **Не-self репозитории / политика auto:** вызвать существующий хук в режиме отката
(`scripts/orchestrator-deploy-hook.sh --rollback` с прод-параметрами окружения,
как в `self_deploy.build_deploy_command`, но action=`--rollback`). Маппинг
exit-code хука (0/1/2) в исход переиспользует логику `self_deploy.map_exit_code_to_status`
по смыслу (0 → откат успешен; 1/2 → откат не выполнен/провалился → громкий алерт).
- **Self-hosting (`orchestrator`) по умолчанию (BR-5):** НЕ откатывать автоматически.
Сформировать громкий алерт (Telegram + Plane-коммент) и запросить ручной approve
отката (по образцу deploy Phase A — статус Plane / Telegram CTA). Откат самого
прод-орка, если выполняется, — только через detached host-процесс (нельзя надёжно
откатить контейнер, который при этом умирает; переиспользовать механику
`self_deploy.initiate_deploy`).
- Команда отката для self НЕ должна ронять прод-контейнер в рамках обычного тика
наблюдения (CLAUDE.md: не ронять/не рестартить прод-контейнер вне явного действия).
### 2.4. Конфигурация — `src/config.py` (расширение `Settings`)
Добавить (env-префикс `ORCH_`, дефолты безопасные):
- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8).
- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting
(по образцу `self_deploy_repos` / `merge_gate_repos` / `image_freshness_repos`).
- `post_deploy_window_s: int = 900` — длина окна наблюдения (дефолт ~15 мин, BR-1).
- `post_deploy_interval_s: int = 30` — интервал между опросами.
- `post_deploy_fail_threshold: int = 3` — N последовательных провалов health → DEGRADED.
- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx на окне → DEGRADED.
- `post_deploy_auto_rollback: bool = False` — глобально разрешён ли авто-откат;
при `True` действует для не-self репо; для self всегда требует approve (BR-5).
- `post_deploy_base_url: str = "http://localhost:8500"` — base-url наблюдаемого прода.
- `post_deploy_target` параметры отката — переиспользовать существующие
`deploy_prod_*` (service/port/image/prev_image_file), новых дублей не вводить.
### 2.5. Артефакт задачи — `16-post-deploy-log.md` (новый)
В `docs/work-items/<plane-id>/`. YAML-frontmatter (машиночитаемо, канон гейтов;
для будущей петли уроков BR-10):
```
---
post_deploy_status: HEALTHY | DEGRADED
action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY
work_item: <plane-id>
window_s: <int>
checks_total: <int>
checks_failed: <int>
---
```
Тело — человекочитаемая сводка опросов. Записывается best-effort (по образцу
`self_deploy.write_deploy_log`); отсутствие файла не должно ничего ронять.
> Артефакт `16-post-deploy-log.md` добавить в перечень артефактов в `CLAUDE.md`
> и таблицу/описание в `docs/architecture/README.md` (golden-source, в том же PR).
### 2.6. Наблюдаемость — `GET /queue` (`src/main.py`) (BR-9)
Добавить блок `post_deploy` со снимком состояния (enabled, window, активные
наблюдения, последний исход) — по образцу блока `reconcile` (метод `status()`).
### 2.7. Изменения схемы БД
**Не требуются.** Состояние наблюдения — sentinel-файлы (restart-safe, без миграции,
по образцу ORCH-36) и/или отложенные jobs. Если архитектор выберет колонку в `tasks`
для отметки наблюдения — потребуется миграция; предпочтительно избежать (как ORCH-36/53/58).
### 2.8. Новые QG checks
**Не требуются.** Наблюдение происходит ПОСЛЕ `done` и не является gate'ом стадии;
реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются (если архитектор НЕ выберет
вариант «отдельная пост-deploy стадия» — тогда потребуется новая стадия+gate, что
надо явно отразить в ADR; по умолчанию предпочтителен вариант без изменения реестров).
## 3. Инварианты (НЕ ломать)
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракт `check_deploy_status` /
`_parse_deploy_status`, момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync
`deploy → done`, merge-gate, exit-code-контракт хука (0/1/2) — без изменений.
- Контракт хука: дефолты STAGING-безопасны; прод-параметры приходят только через env.
- Условность как ORCH-35/36/43/58: реально для `orchestrator`/listed-repos, прочие — no-op.
- Never-raise: ошибка в наблюдении не роняет worker / lifespan / конвейер других проектов.
- Self-hosting: тик наблюдения НИКОГДА не рестартит прод-контейнер сам по себе (BR-5).
## 4. Задействованные модули (сводка)
| Модуль | Изменение |
|--------|-----------|
| `src/post_deploy.py` | **новый** — чистая логика опроса/классификации/решения/артефакта, never-raise |
| `src/config.py` | +параметры `post_deploy_*` (kill-switch, окно, пороги, политика) |
| `src/stage_engine.py` и/или `src/agents/launcher.py` и/или `src/main.py` | арм/исполнение наблюдения (точка — за архитектором) |
| `scripts/orchestrator-deploy-hook.sh` | переиспользуется (`--rollback`); правки — только если откат self требует отдельной ветки (за архитектором) |
| `src/main.py` | блок `post_deploy` в `GET /queue` (BR-9); возможный старт daemon в `lifespan` |
| `docs/work-items/<id>/16-post-deploy-log.md` | **новый** артефакт |
| `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` | обновить (golden-source, в том же PR) |
| ADR | `docs/work-items/ORCH-021/06-adr/ADR-001-*.md` (+ возможный сквозной `adr/adr-00NN`) |
## 5. Артефакты по pipeline, которые должны появиться/обновиться
- `16-post-deploy-log.md` (новый, машиночитаемый frontmatter).
- Обновлённые `CLAUDE.md` (перечень артефактов), `docs/architecture/README.md`
(описание пост-деплой наблюдения), `CHANGELOG.md`.
- ADR work-item (`06-adr/`) с зафиксированным выбором механизма и порогов.

View File

@@ -0,0 +1,106 @@
# Критерии приёмки — ORCH-021
Work Item: ORCH-021
Формат: каждый критерий имеет чёткое условие PASS/FAIL и проверяется тестом
из `04-test-plan.yaml`.
## Наблюдение и сигналы
### AC-1 — наблюдение армится после deploy→done
- **PASS:** для применимого репозитория после терминального перехода `deploy → done`
пост-деплой наблюдение инициируется (создаётся sentinel/отложенный job/запись в watcher).
- **FAIL:** переход `deploy → done` не приводит к старту наблюдения.
### AC-2 — наблюдение НЕ армится для неприменимых репо
- **PASS:** для репозитория вне области (не self-hosting и не в `post_deploy_repos`)
`post_deploy_applies(repo)` → False; наблюдение не стартует; конвейер не меняется.
- **FAIL:** наблюдение стартует для неприменимого репо.
### AC-3 — классификация HEALTHY
- **PASS:** серия опросов без провалов (или провалов меньше `post_deploy_fail_threshold`
и доля 5xx ниже `post_deploy_5xx_threshold`) → вердикт `HEALTHY`.
- **FAIL:** при здоровых сигналах возвращается `DEGRADED`.
### AC-4 — классификация DEGRADED по порогу провалов health
- **PASS:** `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health → `DEGRADED`.
- **FAIL:** порог достигнут, но вердикт не `DEGRADED`.
### AC-5 — классификация DEGRADED по доле 5xx
- **PASS:** доля 5xx на окне выше `post_deploy_5xx_threshold``DEGRADED`,
даже если `/health` отвечает 200.
- **FAIL:** превышение порога 5xx не даёт `DEGRADED`.
### AC-6 — устойчивость к разовому глюку (нет ложного срабатывания)
- **PASS:** одиночный провал (1 < `post_deploy_fail_threshold`) с последующим
восстановлением → итог `HEALTHY`, реакции нет.
- **FAIL:** одиночный разовый провал приводит к `DEGRADED`/откату.
## Реакция
### AC-7 — авто-rollback для не-self репо при политике auto
- **PASS:** при `post_deploy_auto_rollback=True` и НЕ-self репо вердикт `DEGRADED`
приводит к вызову отката (хук `--rollback` с прод-параметрами); `action_taken`
фиксируется как `ROLLBACK_OK`/`ROLLBACK_FAILED` по exit-code.
- **FAIL:** откат не вызывается, либо вызывается с staging-дефолтами, либо роняет прод напрямую.
### AC-8 — self-hosting НЕ откатывается автоматически (safety)
- **PASS:** для `orchestrator` вердикт `DEGRADED` НЕ приводит к автоматическому
откату/рестарту прод-контейнера в тике наблюдения; вместо этого формируется
громкий алерт + запрос ручного approve (`action_taken: ALERT_ONLY`).
- **FAIL:** тик наблюдения автоматически откатывает/рестартит прод-орк.
### AC-9 — откат-провал эскалируется
- **PASS:** если откат вызван и вернул код 1/2 (нет prev-образа / откат тоже упал) →
`action_taken: ROLLBACK_FAILED` + громкий Telegram-алерт о необходимости ручного вмешательства.
- **FAIL:** провал отката проглатывается тихо.
## Конфигурация и совместимость
### AC-10 — kill-switch выключает фичу
- **PASS:** `post_deploy_monitor_enabled=False` → наблюдение не армится ни для кого;
поведение конвейера 1:1 как до ORCH-021.
- **FAIL:** при выключенном флаге наблюдение всё равно работает.
### AC-11 — пороги/окно конфигурируемы через env
- **PASS:** `post_deploy_window_s`, `post_deploy_interval_s`, `post_deploy_fail_threshold`,
`post_deploy_5xx_threshold` читаются из `Settings` (env `ORCH_*`) и влияют на поведение.
- **FAIL:** значения захардкожены.
### AC-12 — реестры и схема БД не изменены
- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, контракт `check_deploy_status` и схема
таблиц БД не изменены (если архитектор не вводит явно новую стадию — тогда это
отражено в ADR и тестах). Существующие тесты deploy/staging/merge-gate зелёные.
- **FAIL:** молча сломан какой-либо существующий контракт/тест.
## Наблюдаемость, артефакт, идемпотентность
### AC-13 — артефакт 16-post-deploy-log.md с машиночитаемым frontmatter
- **PASS:** по итогу наблюдения пишется `16-post-deploy-log.md` с валидным YAML-frontmatter
(`post_deploy_status`, `action_taken`); запись best-effort (её отсутствие ничего не роняет).
- **FAIL:** артефакт не пишется или frontmatter невалиден/непарсится.
### AC-14 — наблюдаемость в /queue
- **PASS:** `GET /queue` содержит блок `post_deploy` со снимком состояния (enabled,
window, активные/последний исход).
- **FAIL:** состояние наблюдения нигде не видно.
### AC-15 — идемпотентность / restart-safe
- **PASS:** повторный арм для той же задачи (двойной webhook / рестарт оркестратора)
не создаёт второе параллельное наблюдение и не теряет уже идущее.
- **FAIL:** дублируется наблюдение или теряется при рестарте.
### AC-16 — never-raise
- **PASS:** любая ошибка опроса/сети/файлов/классификации логируется и НЕ роняет
worker / lifespan / конвейер других проектов.
- **FAIL:** исключение из наблюдения всплывает и ломает обслуживание других проектов.
### AC-17 — уведомления
- **PASS:** ключевые события (наблюдение начато, DEGRADED, откат/алерт, чистое
завершение окна) уведомляются в Telegram и/или Plane-комментарием.
- **FAIL:** деградация/откат происходят молча.
### AC-18 — документация обновлена (golden-source)
- **PASS:** в том же PR обновлены `CLAUDE.md` (артефакт `16-post-deploy-log.md`),
`docs/architecture/README.md` (описание пост-деплой наблюдения), `CHANGELOG.md`,
и заведён ADR work-item.
- **FAIL:** функционал есть, документация не обновлена (reviewer → REQUEST_CHANGES).

View File

@@ -0,0 +1,163 @@
work_item: ORCH-021
description: >
Тест-план пост-деплой мониторинга прода + авто-rollback. Упор на детерминированную
чистую логику классификации/решения (юнит, без сети/LLM) и на интеграцию
армирования наблюдения после deploy->done. Сетевые опросы и хук-вызовы мокируются.
Имена модулей/функций — целевые (src/post_deploy.py); архитектор уточняет точную
сигнатуру, тесты адаптируются под ADR.
tests:
# --- Классификация деградации (чистая логика, ядро) ---
- id: TC-01
type: unit
description: "HEALTHY: серия опросов без провалов (< порога) -> вердикт HEALTHY"
module: tests/test_post_deploy.py
covers: [AC-3]
expected: PASS
- id: TC-02
type: unit
description: "DEGRADED: N последовательных провалов health (== fail_threshold) -> DEGRADED"
module: tests/test_post_deploy.py
covers: [AC-4]
expected: PASS
- id: TC-03
type: unit
description: "DEGRADED по 5xx: доля 5xx выше порога при health=200 -> DEGRADED"
module: tests/test_post_deploy.py
covers: [AC-5]
expected: PASS
- id: TC-04
type: unit
description: "Нет ложного срабатывания: одиночный провал (1 < threshold) + восстановление -> HEALTHY"
module: tests/test_post_deploy.py
covers: [AC-6]
expected: PASS
- id: TC-05
type: unit
description: "Пороги читаются из Settings (env ORCH_*), изменение порога меняет вердикт на тех же данных"
module: tests/test_post_deploy.py
covers: [AC-11]
expected: PASS
# --- Решение о реакции (чистая логика + self-hosting safety) ---
- id: TC-06
type: unit
description: "Решение: не-self репо + auto_rollback=True + DEGRADED -> ROLLBACK"
module: tests/test_post_deploy.py
covers: [AC-7]
expected: PASS
- id: TC-07
type: unit
description: "Решение self-hosting: orchestrator + DEGRADED -> ALERT_ONLY (НИКОГДА не авто-rollback)"
module: tests/test_post_deploy.py
covers: [AC-8]
expected: PASS
- id: TC-08
type: unit
description: "Решение: HEALTHY -> NONE (реакции нет) для любого репо"
module: tests/test_post_deploy.py
covers: [AC-3]
expected: PASS
# --- Условность / kill-switch ---
- id: TC-09
type: unit
description: "post_deploy_applies: пусто в repos -> True только для orchestrator, False для enduro-trails"
module: tests/test_post_deploy.py
covers: [AC-2]
expected: PASS
- id: TC-10
type: unit
description: "kill-switch: post_deploy_monitor_enabled=False -> applies()=False для всех; наблюдение не армится"
module: tests/test_post_deploy.py
covers: [AC-10]
expected: PASS
# --- Маппинг exit-code отката -> исход ---
- id: TC-11
type: unit
description: "Откат exit 0 -> action_taken=ROLLBACK_OK"
module: tests/test_post_deploy.py
covers: [AC-7]
expected: PASS
- id: TC-12
type: unit
description: "Откат exit 1/2 (нет prev-образа / откат упал) -> ROLLBACK_FAILED + эскалация-алерт"
module: tests/test_post_deploy.py
covers: [AC-9]
expected: PASS
# --- Артефакт ---
- id: TC-13
type: unit
description: "16-post-deploy-log.md пишется с валидным YAML-frontmatter (post_deploy_status/action_taken), парсится yaml.safe_load"
module: tests/test_post_deploy.py
covers: [AC-13]
expected: PASS
# --- never-raise ---
- id: TC-14
type: unit
description: "Опрос при сетевой ошибке/таймауте -> консервативный результат (провал-как-down), исключение НЕ всплывает"
module: tests/test_post_deploy.py
covers: [AC-16]
expected: PASS
- id: TC-15
type: unit
description: "Ошибка записи артефакта (нет каталога/IO) -> логируется, функция возвращает False, не raise"
module: tests/test_post_deploy.py
covers: [AC-16, AC-13]
expected: PASS
# --- Интеграция: армирование после deploy->done ---
- id: TC-16
type: integration
description: "advance_stage deploy->done для orchestrator армит наблюдение (sentinel/job создан); для enduro-trails — нет"
module: tests/test_post_deploy_integration.py
covers: [AC-1, AC-2]
expected: PASS
- id: TC-17
type: integration
description: "Идемпотентность: повторный арм той же задачи (двойной webhook) не создаёт второе наблюдение"
module: tests/test_post_deploy_integration.py
covers: [AC-15]
expected: PASS
- id: TC-18
type: integration
description: "Полный цикл DEGRADED -> для не-self вызывается откат (хук замокан), пишется лог, шлётся уведомление"
module: tests/test_post_deploy_integration.py
covers: [AC-7, AC-13, AC-17]
expected: PASS
- id: TC-19
type: integration
description: "Self-hosting DEGRADED: тик НЕ вызывает рестарт/откат прод-контейнера, формирует алерт+approve-запрос"
module: tests/test_post_deploy_integration.py
covers: [AC-8, AC-17]
expected: PASS
# --- Наблюдаемость и обратная совместимость ---
- id: TC-20
type: integration
description: "GET /queue содержит блок post_deploy со снимком состояния"
module: tests/test_post_deploy_integration.py
covers: [AC-14]
expected: PASS
- id: TC-21
type: integration
description: "Регресс: существующие тесты deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS и QG_CHECKS не изменены"
module: tests/test_stages.py
covers: [AC-12]
expected: PASS

View File

@@ -0,0 +1,212 @@
# ADR-001 (ORCH-021): Post-deploy мониторинг прода + реакция на деградацию
## Статус
Proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`.
Сквозной индексный ADR: `docs/architecture/adr/adr-0010-post-deploy-monitor.md`.
Помечено `arch:major-change` (новая под-компонента + новый reserved-agent job-kind).
## Контекст
Конвейер заканчивается на `deploy → done` (`check_deploy_status` видит
`deploy_status: SUCCESS` → terminal-sync, Plane → Done, release merge-lease). После
этого оркестратор **забывает про прод**. «Успех» сегодня = прохождение health-check
в момент рестарта (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60с.
Класс инцидентов «зелёный деплой, красный прод» (прецедент **ET-8**): деградация
проявляется через минуты под боевым трафиком (прогрев кэшей, фоновые миграции,
утечки, рост 5xx), health отвечает `200 ok`, но фича сломана. Для self-hosting это
критично: сломанный прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса.
BRD/ТЗ задают требования (BR-1…BR-11, AC-1…AC-18) и оставляют архитектору **три
открытых вопроса** (BRD §7): (1) где живёт наблюдение — стадия / watchdog-daemon /
reserved-agent job; (2) механизм self-rollback; (3) пороги/веса сигналов.
Существующие переиспользуемые механики:
- **deploy-finalizer** (ORCH-36, `stage_engine.run_deploy_finalizer` + перехват в
`launcher.launch_job` ДО `_spawn`) — детерминированный no-LLM reserved-agent job,
само-перепостановка через `enqueue_job(available_at_delay_s=...)`, defer-budget,
restart-safe (jobs-очередь + sentinel-файлы `.deploy-state-<repo>/<wi>/`).
- **self_deploy.py** — sentinel-state хелперы (`write_marker`/`has_marker`/
`read_result`/`clear_state`), detached host-процесс (`build_deploy_command`/
`initiate_deploy`: ssh + setsid), `map_exit_code_to_status`, `self_deploy_applies`.
- **reconciler.py** — daemon-поток + `status()` в `GET /queue`.
- **хук `--rollback`** (`do_rollback`): retag `PREV_IMAGE_FILE``TARGET_IMAGE` +
рестарт + health, коды 0 / 1 (нет prev-образа) / 2 (rollback тоже упал).
- **Условность** ORCH-35/36/43/58: `is_self_hosting_repo`, флаг + CSV-репо.
## Решение
### 1. Механизм наблюдения — reserved-agent job `post-deploy-monitor` (Вариант B)
Наблюдение реализуется как **детерминированный no-LLM reserved-agent job**, точная
калька **deploy-finalizer**. Один «тик» наблюдения = один job: он делает ОДИН опрос
сигналов, обновляет персистентные счётчики в sentinel-файлах, классифицирует и либо
**перепостанавливает себя** с задержкой `post_deploy_interval_s` (окно не истекло и
ещё не DEGRADED), либо завершает наблюдение (DEGRADED → реакция; либо окно истекло →
HEALTHY). Это «watchdog поверх очереди»: между тиками job не выполняется (он
запланирован в будущем через `available_at_delay_s`), worker свободен для других
проектов — ровно как defer у finalizer.
**Почему НЕ daemon-watchdog (Вариант A, как reconciler):** daemon тикает глобально, а
не per-task; серию опросов (последовательные провалы health, доля 5xx на окне) пришлось
бы держать в памяти → теряется/двоится при рестарте (а сам деплой орка = рестарт). Чтобы
сделать daemon restart-safe, всё равно нужны персистентные per-task счётчики в sentinel —
тогда reserved-agent проще и уже имеет проверенную restart-safe машинерию (jobs-очередь
+ `requeue_running_jobs` + sentinels). Per-task жизненный цикл естественно ложится на
job-цепочку, а не на глобальный sweep.
**Почему НЕ отдельная пост-deploy стадия (Вариант C):** меняет `STAGE_TRANSITIONS` +
реестр `QG_CHECKS` (нарушает AC-12, ТЗ §2.8 — явно непредпочтительно); ломает семантику
`deploy → done` как терминального перехода (Plane уже Done). Наблюдение происходит
**ПОСЛЕ** `done` — «продление ответственности ЗА done», а не новая стадия конвейера.
### 2. Арм наблюдения — хук в terminal-блоке `advance_stage`
В `stage_engine.advance_stage`, в существующем блоке `next_stage == "done"` (после
`set_issue_done` и `release_merge_lease`), добавляется арм:
```
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
```
`arm_monitor` (never-raise): если sentinel `armed` отсутствует → создаёт state-dir,
пишет `armed` (идемпотентность, по образцу `INITIATED`), инициализирует `series`-файл,
ставит первый `post-deploy-monitor` job через `enqueue_job(available_at_delay_s=
post_deploy_interval_s)`. Если `armed` уже есть → no-op (двойной webhook / reconciler
F-1 / finalizer Phase C могут довести `done` повторно — AC-15). Выключенный
kill-switch / неприменимый репо → `post_deploy_applies` False → арма нет (AC-2/AC-10).
### 3. Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise)
По образцу `self_deploy.py` / `staging_verdict.py`. Импортирует только config (+lazy
`qg.checks.is_self_hosting_repo`), НЕ импортирует `stage_engine`/`launcher`. Функции:
- **`post_deploy_applies(repo) -> bool`** — флаг `post_deploy_monitor_enabled` +
CSV `post_deploy_repos` (пусто → только self-hosting). Калька `self_deploy_applies`.
- **`probe_signals(base_url) -> ProbeResult`** — один опрос: `GET /health` (HTTP 200 +
`{"status":"ok"}`) и ключевые эндпоинты `/status`, `/queue` (учёт доли 5xx).
Сеть/таймаут → консервативный «провал»-результат, не исключение.
- **`classify(series, fail_threshold, 5xx_threshold) -> "HEALTHY"|"DEGRADED"`** —
чистая, без сети, **главный предмет юнит-тестов** (детерминированная, как
`compute_staging_verdict`): `DEGRADED` если `≥ fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ
провалов health (AC-4) ИЛИ доля 5xx на окне `> 5xx_threshold` (AC-5). Иначе
`HEALTHY` (одиночный провал < порога с восстановлением → HEALTHY, AC-3/AC-6).
- **`decide_action(repo, verdict) -> "NONE"|"ROLLBACK"|"ALERT_ONLY"`** — чистая:
`HEALTHY → NONE`; `DEGRADED` + self-hosting → `ALERT_ONLY` (BR-5/AC-8, ВСЕГДА);
`DEGRADED` + не-self + `post_deploy_auto_rollback=True``ROLLBACK`; иначе →
`ALERT_ONLY`.
- **Sentinel-state хелперы** (state-dir `.post-deploy-state-<repo>/<wi>/`, по образцу
`self_deploy._state_dir`): `armed`, `series` (JSON-список результатов опросов,
append каждый тик — restart-safe счётчики), `done`. `read_series`/`append_probe`/
`mark_done`/`has_marker` — never-raise.
- **`write_post_deploy_log(...)`** — артефакт `16-post-deploy-log.md`, best-effort
(по образцу `self_deploy.write_deploy_log`).
- **`build_rollback_command(repo)`** — argv хука `--rollback` с прод-env (как
`build_deploy_command`, но action=`--rollback`; переиспользует `deploy_prod_*`).
### 4. Исполнение тика — `stage_engine.run_post_deploy_monitor(job)` + перехват в launcher
По образцу `run_deploy_finalizer` / `_run_deploy_finalizer_job`:
`launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО `_spawn`
`stage_engine.run_post_deploy_monitor(job)`. Алгоритм тика (never-raise):
1. `mark_done` уже стоит → no-op (AC-15, защита от дубля).
2. `probe = post_deploy.probe_signals(base_url)`; `append_probe(series, probe)`.
3. `verdict = classify(series, ...)`.
4. **Если `HEALTHY` и окно не истекло** (число тиков < `window_s/interval_s`) →
перепостановка `post-deploy-monitor` через `available_at_delay_s=interval_s`
(как finalizer defer; счётчик тиков — из jobs-очереди/`series`, restart-safe).
5. **Если `HEALTHY` и окно истекло** → исход `NONE`, `write_post_deploy_log(HEALTHY,
NONE)`, `mark_done`, нотификация «окно завершилось чисто» (BR-6/AC-17).
6. **Если `DEGRADED`** → `action = decide_action(...)`; исполнить реакцию (§5),
`write_post_deploy_log`, `mark_done`, нотификации.
`mark_done` + sentinel `armed` дают идемпотентность; jobs-очередь +
`requeue_running_jobs` + `series` дают restart-safe (AC-15). Бюджет тиков bounded
(`window_s/interval_s`) — анти-livelock, как `deploy_finalize_max_attempts`.
### 5. Реакция на деградацию
- **Self-hosting (`orchestrator`), всегда (BR-5/AC-8):** `ALERT_ONLY`. НЕ откатывать
и НЕ рестартить прод-контейнер в тике. Громкий Telegram + Plane-коммент с запросом
ручного approve отката (по образцу deploy Phase A CTA). `action_taken: ALERT_ONLY`.
Откат самого прод-орка (если оператор решит) — ТОЛЬКО через detached host-процесс
(контейнер не откатит себя, умирая); переиспользуется механика
`self_deploy.initiate_deploy`, но в MVP она вне тика наблюдения (ручной approve →
отдельный путь, как ORCH-54 для авто-deploy). Тик self НИКОГДА не запускает хук
`--rollback` (структурный инвариант).
- **Не-self + `post_deploy_auto_rollback=True` (AC-7):** вызвать хук `--rollback` с
прод-env (`build_rollback_command`). Маппинг exit-code по смыслу
`map_exit_code_to_status`: `0 → ROLLBACK_OK`; `1/2 → ROLLBACK_FAILED` + громкий
Telegram о необходимости ручного вмешательства (AC-9). Целевой контейнер не есть
orchestrator → его рестарт безопасен для конвейера.
- **Не-self + auto_rollback=False (дефолт):** `ALERT_ONLY`.
### 6. Артефакт `16-post-deploy-log.md` (новый, машиночитаемый)
YAML-frontmatter (канон гейтов; для петли уроков ORCH-8, BR-10):
```
---
post_deploy_status: HEALTHY | DEGRADED
action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY
work_item: <plane-id>
window_s: <int>
checks_total: <int>
checks_failed: <int>
---
```
Тело — человекочитаемая сводка опросов. Best-effort (отсутствие файла ничего не роняет,
AC-13). **Не** читается ни одним гейтом — наблюдение происходит после `done`.
### 7. Конфигурация — `src/config.py` (env-префикс `ORCH_`)
- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8/AC-10).
- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting.
- `post_deploy_window_s: int = 900` — окно наблюдения (~15 мин, BR-1).
- `post_deploy_interval_s: int = 30` — интервал опросов.
- `post_deploy_fail_threshold: int = 3` — N послед. провалов health → DEGRADED.
- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx → DEGRADED.
- `post_deploy_auto_rollback: bool = False` — глоб. разрешение авто-отката (для self
всегда требует approve, BR-5).
- `post_deploy_base_url: str = "http://localhost:8500"` — наблюдаемый прод.
- Параметры отката — переиспользовать существующие `deploy_prod_*` (новых дублей нет).
### 8. Наблюдаемость — блок `post_deploy` в `GET /queue` (BR-9/AC-14)
По образцу блока `reconcile` (метод `status()`): `enabled`, `window_s`, `interval_s`,
активные наблюдения (по sentinel `armed` без `done`), последний исход
(`post_deploy_status`/`action_taken`). Best-effort, never-raise.
### Инварианты (НЕ меняются)
`STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`,
момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync `deploy → done`, merge-gate,
exit-code-контракт хука (0/1/2), схема БД. Условность как ORCH-35/36/43/58. Never-raise
во всём наблюдении (AC-16). Тик self НИКОГДА не рестартит прод-контейнер (AC-8).
## Альтернативы
- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия в памяти не
restart-safe; restart-safe-вариант требует тех же sentinel-счётчиков → reserved-agent
проще и уже проверен.
- **Отдельная пост-deploy стадия + QG** — отклонён: меняет реестры (AC-12), ломает
семантику терминального `done`; наблюдение принципиально ПОСЛЕ `done`.
- **Авто-rollback прод-орка из тика** — отклонён (BR-5): контейнер не откатит себя
надёжно; групповой риск для всех проектов. Self → только ALERT + ручной approve.
- **Новая колонка в `tasks` для отметки наблюдения** — отклонён: миграция на проде
(риск, как в adr-0007); sentinel-файлы достаточны и restart-safe (как ORCH-36/53/58).
- **Прометей/APM** — вне рамок (BR out-of-scope): опираемся на существующие
HTTP-эндпоинты, не вводим сбор метрик.
## Последствия
- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация —
машиночитаемый сигнал для петли уроков (ORCH-8).
- Плюс: максимальное переиспользование проверенной finalizer/sentinel/hook-машинерии;
нулевая миграция БД; реестры не тронуты; дефолты безопасны (auto-rollback off, self
только alert).
- Минус/ограничение: монитор self бежит ВНУТРИ наблюдаемого прод-контейнера — если
контейнер полностью wedged, worker может не выполнить тик и алерта не будет (gap).
Это known limitation MVP; внешний независимый watchdog — follow-up (вне рамок).
- Минус: каждый тик на короткое время занимает single-worker (`max_concurrency=1`);
митигируется коротким опросом (~секунды) и `interval_s` между тиками (defer не держит
worker), как finalizer.
- Доменный smoke результата фичи (BR-11) — follow-up; MVP = health + 5xx.
## Связи
- **ET-8** — обоснование (deploy SUCCESS, прод не работает).
- **adr-0007-executable-self-deploy** (ORCH-36) — sentinel-паттерн, detached
host-процесс, `map_exit_code_to_status`, deploy-finalizer reserved-agent (образец).
- **adr-0007-reconciler** (ORCH-53) — daemon/`status()` образец (рассмотрен и отклонён
как основной механизм; `status()`-снимок в `/queue` переиспользуется).
- **adr-0006-merge-gate** / **adr-0003-staging-gate** — образец условности и флагов
раската (`*_enabled` + `*_repos`).
- **adr-0008-staging-image-provenance** — `.deploy-prev-image` / хук-механика отката.
- **ORCH-8** — петля уроков (потребитель `16-post-deploy-log.md`).
- **ORCH-54** — будущий полный авто (включая авто-approve отката self), по аналогии
с авто-deploy.

View File

@@ -0,0 +1,56 @@
# 07 — Инфраструктурные требования (ORCH-021)
> Топология НЕ меняется. Фича опирается на уже существующие HTTP-эндпоинты прода и
> существующий деплой-хук. Этот документ фиксирует, какие инфра-предпосылки должны
> выполняться, чтобы наблюдение и реакция работали.
## 1. Топология — без изменений
- Прод `orchestrator` (8500), staging `orchestrator-staging` (8501), один сервер
mva154 (см. `docs/operations/INFRA.md`). Новых контейнеров/портов/сервисов нет.
- Наблюдение — внутрипроцессный reserved-agent job в worker'е прод-контейнера.
Daemon-потоков не добавляется (в отличие от reconciler).
## 2. Наблюдаемый прод — HTTP-эндпоинты
- Монитор опрашивает `post_deploy_base_url` (дефолт `http://localhost:8500`):
- `GET /health` → ожидается HTTP 200 + тело `{"status":"ok"}` (BR-2);
- `GET /status`, `GET /queue` → учёт доли HTTP 5xx (BR-2).
- Эндпоинты уже существуют (`src/main.py`). Новых эндпоинтов фича НЕ вводит
(out-of-scope APM/метрики).
- Для self-hosting `base_url=localhost:8500` означает: монитор бьёт по собственному
контейнеру. Это допустимо для MVP (см. риск R-1 в `10-tech-risks.md`).
## 3. Деплой-хук `--rollback` — предпосылки реакции
- Реакция ROLLBACK (только не-self + `post_deploy_auto_rollback=True`) вызывает
`scripts/orchestrator-deploy-hook.sh --rollback` с прод-env (переиспользуются
`deploy_prod_*`: `TARGET_SERVICE`/`TARGET_PORT`/`TARGET_IMAGE`/`COMPOSE_PROFILE`/
`PREV_IMAGE_FILE`), по образцу `self_deploy.build_deploy_command`.
- Предпосылка: при штатном деплое хук сохраняет предыдущий образ в
`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`). Без снимка → хук вернёт exit 1
(«нет prev-образа») → `ROLLBACK_FAILED` + алерт (AC-9). Контракт exit-кодов хука
(0/1/2) НЕ меняется.
- **Self-hosting:** откат прод-орка хуком в тике ЗАПРЕЩЁН (контейнер не откатит себя,
умирая). Если оператор по алерту решит откатить — только detached host-процесс
(ssh + setsid, механика `self_deploy.initiate_deploy`), как у Phase B самодеплоя.
Предпосылки для detached-пути (ssh-доступ host, shared-mount state-dir) уже
выполнены для ORCH-36; в MVP detached-откат self вне тика наблюдения.
## 4. Restart-safe состояние — shared mount
- Состояние наблюдения — sentinel-файлы под `.post-deploy-state-<repo>/<wi>/`
(`armed`, `series`, `done`) на том же mount `settings.repos_dir`, что и
`.deploy-state-*` (ORCH-36). Миграции БД нет (см. `08-data-requirements.md`).
- `requeue_running_jobs` (ORCH-1) восстанавливает claimed `post-deploy-monitor` job
после рестарта; `series` хранит счётчики опросов → наблюдение продолжается
с того же места (BR-7/AC-15).
## 5. Конфигурация окружения (env `ORCH_*`)
Новые ключи (дефолты безопасны, в `.env`/`.env.staging` по необходимости):
`post_deploy_monitor_enabled` (kill-switch, дефолт true), `post_deploy_repos` (CSV,
пусто → self-hosting), `post_deploy_window_s` (900), `post_deploy_interval_s` (30),
`post_deploy_fail_threshold` (3), `post_deploy_5xx_threshold` (0.5),
`post_deploy_auto_rollback` (false), `post_deploy_base_url` (localhost:8500).
Параметры отката — существующие `deploy_prod_*`, новых дублей не вводить.
## 6. Чего НЕ требуется
- Новых контейнеров, портов, сетевых правил, секретов.
- Prometheus / Grafana / APM (out-of-scope).
- Изменений compose-топологии или деплой-пути не-self репо.

View File

@@ -0,0 +1,40 @@
# 08 — Требования к данным / схеме БД (ORCH-021)
## Вывод: миграция БД НЕ требуется
Состояние наблюдения хранится в **sentinel-файлах** (restart-safe, без миграции —
по образцу ORCH-36/53/58), а не в таблицах. Реестры и схема не меняются (AC-12).
## 1. Существующие таблицы — без изменений
- `events`, `tasks`, `agent_runs`, `jobs` — структура не меняется.
- В `tasks` НЕ вводится колонка статуса/окна наблюдения (намеренно — миграция на
проде = риск, как обосновано в adr-0007; альтернатива отклонена в ADR-001 §Альтернативы).
## 2. Очередь `jobs` — переиспользование, без схемы
- `post-deploy-monitor` — новый **job-kind** (значение в существующей колонке
`agent`/`task_content`), НЕ новая колонка. Ставится через существующий
`enqueue_job(..., available_at_delay_s=...)` (ORCH-1).
- Счётчик тиков/деферов восстанавливается из jobs-очереди (как
`_deploy_finalize_defer_count` считает по `task_content LIKE`), restart-safe.
## 3. Sentinel-состояние (файлы, не БД)
State-dir `.post-deploy-state-<repo>/<work_item_id>/` на `settings.repos_dir`
(по образцу `.deploy-state-*`):
| Файл | Назначение |
|------|------------|
| `armed` | наблюдение заармлено (идемпотентность арма; калька `INITIATED`) |
| `series` | JSON-список результатов опросов (счётчики health-fail / 5xx; restart-safe) |
| `done` | наблюдение завершено (защита от повторной обработки) |
Все обращения — never-raise (по образцу `self_deploy.has_marker`/`write_marker`/
`read_result`). Отсутствие/битость файла → консервативный фоллбэк, не исключение.
## 4. Артефакт `16-post-deploy-log.md` — файл репозитория, не БД
Машиночитаемый YAML-frontmatter (`post_deploy_status`, `action_taken`, `window_s`,
`checks_total`, `checks_failed`) пишется best-effort в `docs/work-items/<id>/`; в БД
не реплицируется. Источник для петли уроков ORCH-8 (BR-10).
## 5. Очистка состояния
По завершении окна / реакции `done`-маркер ставится; state-dir можно чистить
best-effort (по образцу `self_deploy.clear_state`) — необязательно для корректности,
но желательно для гигиены. Stale-`armed` без `done` после краха → виден в `/queue`
как «активное наблюдение» и доигрывается восстановленным job'ом.

View File

@@ -0,0 +1,20 @@
# 10 — Технические риски (ORCH-021)
| # | Риск | Вероятн. | Влияние | Митигация |
|---|------|----------|---------|-----------|
| R-1 | **Монитор self бежит внутри наблюдаемого прода.** Полностью wedged прод-контейнер → worker не выполнит тик → деградация не замечена, алерта нет. | Сред. | Высок. | Known MVP limitation (зафиксировано в ADR-001 §Последствия). Health в момент рестарта (хук) + reconciler ловят часть случаев. Внешний независимый watchdog — follow-up (вне рамок). |
| R-2 | **Ложный авто-rollback** по сетевому глюку. | Низк. | Высок. | Пороги по N ПОСЛЕДОВАТЕЛЬНЫХ провалов + доля 5xx на окне (BR-3/AC-6), а не разовый провал. Self ВСЕГДА `ALERT_ONLY` (BR-5). `auto_rollback=False` по умолчанию. |
| R-3 | **Авто-rollback прод-орка убивает инструмент всех проектов.** | Низк. | Критич. | Структурный инвариант: тик self НИКОГДА не откатывает/рестартит прод-контейнер (AC-8). Self → только alert + ручной approve. Откат self — только detached host-процесс вне тика. |
| R-4 | **Нет prev-образа** при ROLLBACK → откат невозможен. | Сред. | Сред. | Хук возвращает exit 1 → `ROLLBACK_FAILED` + громкий алерт (AC-9), деградация не проглатывается тихо. |
| R-5 | **Дубль/потеря наблюдения** при двойном webhook / рестарте. | Сред. | Сред. | Идемпотентность: sentinel `armed` (арм-гард) + `done` (защита от повторной обработки) + restart-safe jobs-очередь + `series` (AC-15). По образцу finalizer. |
| R-6 | **Исключение в наблюдении роняет worker / конвейер других проектов.** | Низк. | Высок. | Контракт never-raise во всём `post_deploy.py` и `run_post_deploy_monitor` (AC-16), по образцу `self_deploy`/`staging_verdict`. |
| R-7 | **Тик занимает single-worker** (`max_concurrency=1`) → задержка других задач. | Низк. | Низк. | Опрос короткий (~секунды), между тиками job не выполняется (defer через `available_at_delay_s`) — worker свободен, как у finalizer. Окно bounded (`window_s/interval_s`). |
| R-8 | **Скрытое изменение контракта** (реестры/гейты/exit-коды/схема). | Низк. | Высок. | Инвариант: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_deploy_status`/terminal-sync/merge-gate/exit-коды/схема БД НЕ меняются (AC-12). Существующие тесты deploy/staging/merge-gate должны остаться зелёными. |
| R-9 | **5xx на `/queue`/`/status` из-за самого монитора** (рекурсивная нагрузка). | Низк. | Низк. | Интервал `post_deploy_interval_s` (30с) — низкая частота; опрос лёгкий GET. |
| R-10 | **Артефакт `16-post-deploy-log.md` не пишется / невалиден** → петля уроков без данных. | Низк. | Низк. | Best-effort запись с валидным frontmatter (AC-13); отсутствие файла ничего не роняет. Парсинг — defensive. |
## Эскалация
- Изменение помечено `arch:major-change` (новая под-компонента `src/post_deploy.py`
+ новый reserved-agent job-kind `post-deploy-monitor`).
- R-1 (gap наблюдения для wedged self-контейнера) — кандидат на отдельную задачу
(внешний watchdog), вне рамок ORCH-021.

View File

@@ -0,0 +1,99 @@
---
type: review
work_item_id: ORCH-021
verdict: APPROVED
version: 2
---
# Review ORCH-021 — Post-deploy мониторинг прода + реакция на деградацию
## Summary
Реализация продлевает ответственность конвейера ЗА терминальный переход
`deploy → done`, закрывая класс инцидентов «зелёный деплой, красный прод» (ET-8).
Механизм — детерминированный reserved-agent job `post-deploy-monitor` (вариант B
из ADR-001, точная калька `deploy-finalizer`): арм в `stage_engine.advance_stage`
(блок `next_stage == "done"`), один тик = один job (перехват в
`launcher.launch_job` ДО `_spawn``stage_engine.run_post_deploy_monitor`),
чистая логика в новом leaf-модуле `src/post_deploy.py` (never-raise).
Проверены все четыре оси. Реализация соответствует ТЗ (`02-trz.md`), ADR-001 и
глобальному adr-0010, удовлетворяет всем критериям приёмки AC-1…AC-18.
Документация (golden-source) обновлена в том же PR. Регрессов нет.
## Соответствие ТЗ
- §2.1 `src/post_deploy.py` (leaf, never-raise): `post_deploy_applies`,
`probe_signals`, `classify`, `decide_action`, sentinel-state, артефакт,
`build_rollback_command` — все на месте. ✅
- §2.2 Оркестрация: арм в terminal-блоке + reserved-agent тик с
само-перепостановкой через `available_at_delay_s`; restart-safe (sentinel
`armed`/`series`/`done` + jobs-очередь). ✅
- §2.3 Реакция: non-self+auto → хук `--rollback` (синхронно, целевой ≠ orch);
self-hosting → ВСЕГДА `ALERT_ONLY`. ✅
- §2.4 Конфигурация: все `post_deploy_*` в `src/config.py`, дефолты безопасны
(kill-switch on, auto-rollback off), параметры отката переиспользуют
`deploy_prod_*`. ✅
- §2.5 Артефакт `16-post-deploy-log.md` с машиночитаемым frontmatter,
best-effort. ✅
- §2.6 Блок `post_deploy` в `GET /queue`. ✅
- §2.7/§2.8/§3 Инварианты: `STAGE_TRANSITIONS`, `QG_CHECKS`,
`check_deploy_status`, terminal-sync, merge-gate, exit-code-контракт хука,
схема БД — не тронуты (подтверждено зелёным полным прогоном). ✅
## Соответствие ADR
Реализация 1:1 повторяет ADR-001: механизм (reserved-agent, не стадия/не daemon),
точки интеграции, пороги BR-3, политика реакции BR-5 (self never auto-rollback —
структурный инвариант в `decide_action` + отсутствие вызова `run_rollback` на
ALERT_ONLY). Нарушений глобальных ADR не выявлено.
## Качество кода
- Контракт never-raise выдержан во всех публичных функциях и в каждой ветке
`run_post_deploy_monitor`; launcher оборачивает тик в доп. guard (AC-16).
- `classify` fail-safe → HEALTHY на мусорном входе (ложный DEGRADED опаснее).
- Docstrings содержательные, со ссылками на AC/BR.
- Условность раската по образцу ORCH-35/36/43/58 (флаг + CSV-репо).
## Тесты
30 тестов ORCH-021 (`tests/test_post_deploy.py`,
`tests/test_post_deploy_integration.py`) — содержательные, покрывают
классификацию (AC-3..6), self-hosting safety (TC-19 явно проверяет, что хук
`--rollback` НЕ вызывается для self — AC-8), idempotency двойного арма (AC-15),
kill-switch/условность (AC-2/10/11), exit-code маппинг (AC-9), frontmatter
артефакта (AC-13), never-raise (AC-16), `/queue` (AC-14). Полный прогон
`pytest tests/`**701 passed** (регрессов нет, AC-12).
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice to have
- [ ] `run_post_deploy_monitor`: в ветке `ALERT_ONLY` для **не-self** репо при
`post_deploy_auto_rollback=false` текст алерта упоминает «авто-rollback для
self-hosting запрещён (BR-5)», что для не-self случая формулировка не совсем
точна (косметика сообщения; на поведение не влияет).
- [ ] `write_post_deploy_log` коммитит/пушит артефакт в ветку задачи, которая к
моменту наблюдения уже слита/может быть удалена — артефакт может не попасть в
`main`. Контракт best-effort соблюдён (never-raise, ничего не роняет); как
улучшение наблюдаемости — рассмотреть запись лог-артефакта отдельным путём.
## Документация
Обновлено в том же PR (golden-source, AC-18 — PASS):
- `CLAUDE.md``16-post-deploy-log.md` добавлен в перечень артефактов;
- `docs/architecture/README.md` — раздел «Post-deploy наблюдение прода» + блок
`post_deploy` в таблице API `/queue`;
- `docs/architecture/adr/adr-0010-post-deploy-monitor.md` — новый сквозной ADR;
- `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` — детальный ADR;
- `CHANGELOG.md` — запись в `Added` (+ fix Dockerfile `COPY data/`);
- `README.md` / `.env.example` — все `ORCH_POST_DEPLOY_*` env задокументированы.
Изменение `src/` сопровождено обновлением документации — правило CLAUDE.md №2/№6
выполнено.
## Вердикт
Только P3 (nice-to-have) findings, блокеров и must-fix нет → **APPROVED**.

View File

@@ -0,0 +1,82 @@
---
type: test-report
work_item_id: ORCH-021
result: PASS
---
# Test Report — ORCH-021
Post-deploy наблюдение прода + реакция на деградацию (reserved-agent job
`post-deploy-monitor`, leaf-модуль `src/post_deploy.py`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (asyncio mode=AUTO, anyio 4.13.0)
- Ветка: feature/ORCH-021-post-deploy-rollback
- Дата: 2026-06-07
## Прогон
- `pytest tests/ -v --tb=short`**701 passed, 1 warning** (Pydantic V2 deprecation, не относится к задаче).
- Целевые модули `tests/test_post_deploy.py` + `tests/test_post_deploy_integration.py`**30 passed**.
## Smoke-test (read-only, прод 8500)
`curl` в окружении недоступен — опрос через `python urllib` (read-only, прод-контейнер не трогается).
| Эндпоинт | Результат |
|----------|-----------|
| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | 200, активная задача ORCH-021 на стадии `testing` |
| `GET /queue` | 200, counts/resilience/reconcile присутствуют |
> Примечание: блок `post_deploy` в **живом** `/queue` отсутствует — это ожидаемо: прод
> сейчас работает на коде ДО ORCH-021 (задача ещё не задеплоена, стадия testing).
> Наличие блока (AC-14) проверяется интеграционным тестом TC-20 против кода ветки → PASS.
> Smoke-проверка подтверждает живость окружения, не версию ветки.
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Покрывает AC | Тест-функция | Результат |
|-------|----------|--------------|--------------|-----------|
| TC-01 | HEALTHY: серия без провалов < порога | AC-3 | test_tc01_healthy_no_failures | PASS |
| TC-02 | DEGRADED: N посл. провалов health == threshold | AC-4 | test_tc02_degraded_consecutive_health_failures | PASS |
| TC-03 | DEGRADED по 5xx при health=200 | AC-5 | test_tc03_degraded_by_5xx_ratio_even_when_health_200 | PASS |
| TC-04 | Нет ложного срабатывания: одиночный глюк + восстановление | AC-6 | test_tc04_no_false_trip_single_glitch_then_recovery | PASS |
| TC-05 | Пороги из Settings меняют вердикт на тех же данных | AC-11 | test_tc05_thresholds_change_verdict_on_same_data, test_classify_uses_settings_thresholds | PASS |
| TC-06 | не-self + auto_rollback=True + DEGRADED → ROLLBACK | AC-7 | test_tc06_nonself_auto_rollback_degraded_rolls_back | PASS |
| TC-07 | self-hosting + DEGRADED → ALERT_ONLY (никогда не авто-rollback) | AC-8 | test_tc07_self_hosting_degraded_never_rolls_back | PASS |
| TC-08 | HEALTHY → NONE для любого репо | AC-3 | test_tc08_healthy_means_none_for_any_repo, test_nonself_default_policy_alert_only | PASS |
| TC-09 | post_deploy_applies: пусто → только orchestrator | AC-2 | test_tc09_applies_empty_repos_only_self_hosting, test_tc09_applies_explicit_repos_csv | PASS |
| TC-10 | kill-switch: monitor_enabled=False → applies()=False для всех | AC-10 | test_tc10_kill_switch_disables_for_everyone | PASS |
| TC-11 | Откат exit 0 → ROLLBACK_OK | AC-7 | test_tc11_rollback_exit0_is_ok | PASS |
| TC-12 | Откат exit 1/2 → ROLLBACK_FAILED + эскалация | AC-9 | test_tc12_rollback_exit_nonzero_is_failed | PASS |
| TC-13 | 16-post-deploy-log.md: валидный YAML-frontmatter | AC-13 | test_tc13_log_frontmatter_parses | PASS |
| TC-14 | Опрос при сетевой ошибке → консервативный, не raise | AC-16 | test_tc14_probe_network_error_is_conservative_not_raise, test_tc14_classify_junk_input_swallowed | PASS |
| TC-15 | Ошибка записи артефакта → False, не raise | AC-16, AC-13 | test_tc15_write_log_no_worktree_returns_false | PASS |
| TC-16 | advance_stage deploy→done армит наблюдение (self), не армит (non-self) | AC-1, AC-2 | test_tc16_arm_for_self_hosting, test_tc16_no_arm_for_nonself, test_tc16_no_arm_when_kill_switch_off | PASS |
| TC-17 | Идемпотентность: повторный арм не задваивает | AC-15 | test_tc17_double_arm_is_noop | PASS |
| TC-18 | Полный цикл DEGRADED → не-self откат + лог + уведомление | AC-7, AC-13, AC-17 | test_tc18_degraded_nonself_rolls_back | PASS |
| TC-19 | Self-hosting DEGRADED → НЕ рестарт/откат, алерт+approve | AC-8, AC-17 | test_tc19_degraded_self_hosting_alert_only | PASS |
| TC-20 | GET /queue содержит блок post_deploy | AC-14 | test_tc20_queue_block_present | PASS |
| TC-21 | Регресс: deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS/QG_CHECKS не изменены | AC-12 | tests/test_stages.py (+ полный прогон 701) | PASS |
Доп. тесты ветки (не из плана, подтверждают контракты): `test_series_append_and_read_roundtrip`,
`test_mark_done_idempotency_marker`, `test_healthy_tick_requeues_without_finishing`,
`test_finished_window_tick_is_noop` — все PASS.
## Покрытие критериев приёмки
AC-1…AC-18 — все покрыты прошедшими тестами (см. таблицу). AC-12 (реестры/схема БД
не изменены) дополнительно подтверждён зелёным полным регрессом 701 теста, включая
deploy/staging/merge-gate/reconciler. AC-18 (документация) — вне scope прогона тестов,
подтверждён ревью (12-review.md, verdict APPROVED).
## Вывод pytest (хвост)
```
======================= 701 passed, 1 warning in 12.71s ========================
```
```
======================== 30 passed, 1 warning in 0.58s =========================
```
## Итог
**PASS.** Все 21 тест-кейс плана зелёные, полный регресс (701) зелёный, smoke прод-эндпоинтов
OK (окружение живо). Существующие контракты не сломаны. Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,42 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T14:37:33Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Verdict: **SUCCESS** (exit 0).
Run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001)
via the Docker Engine API over the mounted socket (`docker` CLI is not installed
in the prod-agent container; `network_mode: host` + group `999` allow direct
socket access):
```
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
## Result
```
RESULT: 8/10 checks PASS
REAL failed : none
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
tolerance: staging_infra_tolerance_enabled=True
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
```
- **Block A (SMOKE):** A1 `/health` 200 ok, A2 `/queue` 200, A3 `ORCH_STAGING=true` — all PASS.
- **Block B (ACCESS):** B4 Plane sandbox, B5 Gitea `orchestrator-sandbox` (push=true),
B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
- **Block C (E2E, stub):** C7 create issue in SANDBOX, C8 trigger pipeline via
`/webhook/plane` — PASS. C9a/C9b FAILED but are sandbox-infra checks (bot accounts
not members of the SANDBOX Plane project) — **waived** per ORCH-061; not a pipeline
regression. Cleanup deleted the test Plane issue (HTTP 204).
All REAL pipeline checks are green; the only failures are the two known
sandbox-infra checks, which the verdict tolerates (`staging_infra_tolerance_enabled=true`).
The script exited 0 → advance.

View File

@@ -0,0 +1,30 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T18:02:27+00:00
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed via canonical run (ORCH-048, ADR-001):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
**Result: 8/10 checks PASS — exit code 0 (advance).**
All REAL (pipeline) checks green: A1, A2, A3 (SMOKE), B4, B5, B6 (ACCESS), C7, C8 (E2E).
Two sandbox-infra-only checks failed and were waived per ORCH-061
(`staging_infra_tolerance_enabled=True`) — these depend on SANDBOX bot accounts
being members of the SANDBOX Plane project, not on the pipeline:
```
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
```
Cleanup ran (Plane SANDBOX test issue deleted, HTTP 204). Exit code 0 → `staging_status: SUCCESS`.

View File

@@ -0,0 +1,131 @@
---
staging_status: FAILED
timestamp: 2026-06-07T11:01:00Z
base_url: http://localhost:8501
---
# Staging Gate Log — ORCH-058
Staging test suite ran against the live staging environment and **FAILED** (exit code `1`,
**8/10 checks PASS**). Block C (E2E) checks C9a and C9b failed.
Per the staging-gate contract this is the machine verdict `FAILED` (it reflects the real suite
exit code, never an LLM declaration). Smoke (A1A3) and access (B4B6) all passed, **including
B6 registry isolation** — so this is NOT a B6/ORCH-048 false-FAIL.
> ⚠️ **CORRECTED ROOT CAUSE — read before acting on this rollback.** The previous revision of
> this log blamed `handle_status_start` / a regression in the validated artifact. **That was
> wrong**, which is why the dev↔staging cycle kept repeating. Direct inspection inside the
> running staging instance proves the production code is **correct** and the failure is a bug in
> the **test harness `scripts/staging_check.py`**. Do NOT touch `src/webhooks/plane.py` /
> `handle_status_start` / any ORCH-058 image-freshness code. **Fix `scripts/staging_check.py`.**
## Execution
- Canonical `docker exec` into `orchestrator-staging` (ORCH-048, ADR-001), invoked via the
Docker Engine API over the mounted unix socket (the `docker` CLI binary is absent in the
agent runtime image; the Engine-API exec is the exact equivalent of
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`).
- Script: `/repos/orchestrator/scripts/staging_check.py` (bind-mount, served from the host repo,
NOT baked into the image — so a harness fix takes effect on the next run without a rebuild).
- Mode: `stub`
- Exit code: `1`
- Result: **8/10 checks PASS** (FAIL: C9a, C9b)
- Staging image under test: `orchestrator-orchestrator-staging`, OCI label
`org.opencontainers.image.revision=094b5e2f960f696216f8661ff9c27b0d4706f219` (= the **merge
commit of ORCH-058 into `main`**, PR #57; ancestor of branch HEAD `60e5596e`). Container
recreated 2026-06-07T10:13:36Z. So the artifact under test genuinely contains the validated
ORCH-058 code.
## Decisive root cause (proven, actionable)
Block C creates a SANDBOX Plane issue (C7 ✓), then POSTs a signed `/webhook/plane` payload to
start the pipeline (C8 ✓ — HTTP 200 `{"status":"accepted"}`). The staging instance logged for
the test issue `427cb94e-…`:
```
2026-06-07 10:59:04 [INFO] orchestrator.webhooks.plane: issue 427cb94e-cedd-4def-ba5d-21c555a82477
updated to state b873d9eb..., no pipeline action
```
`handle_issue_updated` (src/webhooks/plane.py) starts the pipeline **only** when the webhook's
new state equals the **incoming project's** `in_progress` state, resolved per-project from the
Plane API by `get_project_states(project_id)` (ORCH-10). The webhook the harness sends carries
state `b873d9eb-993c-48cd-97ac-99a9b1623967`.
**The mismatch (queried live inside the staging container):**
| | UUID |
|---|---|
| `staging_check.py` `IN_PROGRESS_STATE_ID` (hardcoded) | `b873d9eb-993c-48cd-97ac-99a9b1623967` |
| `get_project_states(SANDBOX)["in_progress"]` (real) | `84a76f65-75f8-4022-9554-379dad38523c` |
| `_DEFAULT_STATES["in_progress"]` (enduro-trails fallback) | `b873d9eb-993c-48cd-97ac-99a9b1623967` |
The hardcoded `b873d9eb…` is the **enduro-trails** In Progress UUID (the `_DEFAULT_STATES`
fallback), **not** SANDBOX's. SANDBOX's actual In Progress is `84a76f65…`. So the handler
**correctly** classifies the enduro-state webhook as `no pipeline action` for a SANDBOX issue →
no `tasks` row, no Gitea branch (C9a FAIL after 60s), no analyst job enqueued (C9b FAIL).
Cleanup confirmed `no task row found` and `no branch to delete`.
**Why it intermittently "passed 10/10" before (09:31):** `get_project_states` falls back to
`_DEFAULT_STATES` (= `b873d9eb…`) whenever the Plane states API call fails / returns no
recognisable states. On runs where that fallback fired, the hardcoded harness state accidentally
matched and the pipeline started. On this run the SANDBOX states API call succeeded at startup
(`GET …/projects/8c5a3025-…/states/ → 200 OK`), so SANDBOX resolved to its real `84a76f65…` and
the accidental match disappeared. The green runs were the bug; the red runs are correct handler
behaviour exposing a harness that hardcodes the wrong project's state.
## Required fix (for the development rollback) — in `scripts/staging_check.py` ONLY
Make the E2E harness send SANDBOX's **actual** `in_progress` state instead of a hardcoded enduro
UUID. Resolve it dynamically the same way the app does — e.g. `GET
/workspaces/<slug>/projects/<SANDBOX_PROJECT_ID>/states/`, pick the state whose `name` is
`"In Progress"` (group `"started"`), and use its `id` in `_make_webhook_payload`. (The harness
already calls the Plane API for B4/B6, so credentials/URL are available.) Do **not** rely on the
`_DEFAULT_STATES` fallback coincidence. No production-code change is warranted; ORCH-058's
image-provenance feature is unaffected by this and is functioning.
## Test output
```
============================================================
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-07T10:59:02.392888+00:00
============================================================
[Block A] SMOKE
✓ PASS A1 GET /health → 200 status=ok [HTTP 200, body={'status': 'ok', 'service': 'orchestrator'}]
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience [HTTP 200, keys=['counts', 'max_concurrency', 'poll_interval', 'resilience', 'reconcile', 'recent']]
✓ PASS A3 ORCH_STAGING=true (not prod) [ORCH_STAGING=true]
[Block B] ACCESS
✓ PASS B4 Plane: sandbox project accessible [HTTP 200, found 5 project(s), sandbox=YES]
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true [HTTP 200, permissions={'admin': True, 'push': True, 'pull': True}]
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
[Block C] E2E (mode=stub)
· C7: Creating issue in SANDBOX project...
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=427cb94e-cedd-4def-ba5d-21c555a82477]
· C8: Triggering pipeline via POST /webhook/plane ...
· Using HMAC signature (secret len=40)
✓ PASS C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
· C9a: Polling for branch in orchestrator-sandbox (up to 60s)...
· waiting... (waiting for branch) [×20]
✗ FAIL C9a Branch appears in orchestrator-sandbox [branch=not found]
· C9b: Checking staging job queue for analyst job (up to 30s)...
· (Plane comment check skipped: bot-tokens not added to SANDBOX project)
· waiting... (waiting for analyst job in queue) [×15]
✗ FAIL C9b Analyst job enqueued in staging queue
[CLEANUP]
· CLEANUP: no branch to delete
✓ PASS CLEANUP: deleted Plane issue 427cb94e-cedd-4def-ba5d-21c555a82477 (HTTP 204)
· CLEANUP DB: no task row found for plane_id=427cb94e-cedd-4def-ba5d-21c555a82477
· CLEANUP DB dedup: no such table: events_dedup
============================================================
RESULT: 8/10 checks PASS
============================================================
```
EXIT_CODE=1

View File

@@ -0,0 +1,29 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T19:19:25Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Verdict: **SUCCESS** (exit 0).
Canonical run inside the `orchestrator-staging` container (ORCH-048, ADR-001):
`python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
## Result
- RESULT: 8/10 checks PASS
- REAL failed: none
- SANDBOX_INFRA failed: C9a (branch in orchestrator-sandbox), C9b (analyst job enqueued)
All REAL pipeline checks (Block A SMOKE, Block B ACCESS incl. B6 registry isolation,
C7/C8) are green. The two failing checks are sandbox-infra-only (SANDBOX bot accounts
not members of the SANDBOX Plane project) and were waived per ORCH-061. Exit code 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
```
tolerance: staging_infra_tolerance_enabled=True

View File

@@ -0,0 +1,7 @@
# Business Request: Reconciler не должен трогать escalated / max-retries задачи
Work Item ID: ORCH-060
## Description
TBD

View File

@@ -0,0 +1,90 @@
# BRD: Reconciler не должен трогать escalated / max-retries задачи
Work Item ID: ORCH-060
Стадия: analysis → architecture
Связано: ORCH-053 (reconciler), ORCH-046 (retry-счётчик), ORCH-047 (BLOCKED-вердикт)
## 1. Контекст и проблема
ORCH-053 ввёл фоновый reconciler (`src/reconciler.py`) — sweeper, доигрывающий
пропущенные webhook-переходы. Слой F-1 (`reconcile_gate_once`
`_reconcile_gate_task`) для каждой не-терминальной задачи (`stage != 'done'`) без
активного job и старше grace делает read-only пред-оценку канонического QG; если
гейт зелёный → `advance_if_gate_passed``advance_stage(..., finished_agent=None)`.
**Дефект.** Задача, исчерпавшая лимит developer-ретраев
(`_developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES = 3`), **escalated**
но эскалация в обработчиках Gitea (`src/webhooks/gitea.py:280` для CI-failure,
`:371` для review REQUEST_CHANGES) выполняет ТОЛЬКО `notify_error(...)`:
- стадия НЕ меняется (остаётся `development`);
- терминального маркера в БД нет (нет `blocked`-флага в таблице `tasks`);
- активного job нет.
Для reconciler такая задача неотличима от «застрявшей из-за потерянного webhook».
Если CI к этому моменту зелёный (типичный кейс: разработчик починил CI, но reviewer
продолжал слать REQUEST_CHANGES → ушли в лимит), F-1 каждые `reconcile_interval_s`
(120 с) видит зелёный `check_ci_green` и **разблокирует** задачу `development → review`.
Reviewer снова REQUEST_CHANGES → откат на `development` → снова эскалация (стадия
не меняется). Следующий тик — снова разблокировка. Бесконечный цикл.
**Реальный инцидент (наблюдение 0607.06.2026).** ET-013 разблокирована
reconciler'ом **10 раз за ночь**, в итоге всё равно escalated — бесполезный поллинг
каждые 2 минуты, лишние запуски агентов (токены, деньги), шум в Telegram
(`reconcile_notify_unblock`), нагрузка на конвейер общего инстанса (self-hosting:
один инстанс обслуживает ORCH + enduro-trails).
Симметричный риск: задача, которую человек/агент явно перевёл в Plane-статус
**Blocked** или **Needs Input** (ручной гейт), не должна автоматически
разблокироваться reconciler'ом до вмешательства человека.
## 2. Бизнес-цель
Reconciler (F-1) обязан **пропускать** (не трогать) задачи, которые:
1. исчерпали лимит developer-ретраев (`_developer_retry_count >= MAX_DEVELOPER_RETRIES`), и/или
2. находятся в явном «человеческом»/терминальном Plane-статусе **Blocked** / **Needs Input**.
Такие задачи ждут ручного вмешательства; автоматический sweeper их игнорирует.
## 3. Заинтересованные стороны
- **Owner проекта** — прекращение «фантомной» активности и шума по escalated-задачам.
- **Другие проекты на инстансе (enduro-trails)** — снижение паразитной нагрузки общей очереди.
- **Агенты-разработчики оркестратора** — корректная семантика терминального состояния.
## 4. Объём (Scope)
### Входит
- Гард в F-1 (`_reconcile_gate_task` / `advance_if_gate_passed`), который ДО
оценки гейта и вызова `advance_stage` пропускает escalated-задачи
(retry-count >= лимит) — детерминированно, без сети.
- Гард, пропускающий задачи в Plane-статусе Blocked / Needs Input.
- Тесты (unit) на оба условия + регресс happy-path и отсутствия спама/нотификаций.
- Обновление документации: `docs/architecture/README.md` (описание F-1),
per-work-item ADR, `CHANGELOG.md`.
### Не входит
- Изменение порога `MAX_DEVELOPER_RETRIES` или логики самой эскалации в `gitea.py`.
- Изменение F-2 plane-side по существу (F-2 уже реагирует только на
in_progress/approved/rejected, то есть Blocked/Needs Input им не доигрываются —
достаточно регресс-теста, фиксирующего это поведение).
- Реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схема прочих стадий.
## 5. Допущения и ограничения
- **Инвариант reconciler (ORCH-053):** схема БД и реестры не меняются. Решение
должно либо обойтись без миграции, либо архитектор обязан явно обосновать
необходимость нового столбца как терминального маркера.
- **Never-raise:** гард не должен ломать тик; любая ошибка вычисления условия →
безопасный фоллбэк (не трогать задачу — консервативно).
- **self-hosting:** нельзя ронять/рестартить прод-контейнер; изменение — чисто
логика sweeper'а, деплой через staging (8501) по канону.
- Источник истины по retry — `agent_runs` (как у `_developer_retry_count`).
## 6. Критерий успеха (бизнес)
После выката на конкретной escalated-задаче (как ET-013): за ночь — **0**
строк `reconciler: <wi> ... разблокирована`, **0** повторных запусков агентов,
**0** Telegram-нотификаций разблокировки; задача спокойно ждёт человека в
`development`/Blocked. При этом штатные «честно застрявшие» задачи
(retry < лимита, не Blocked) reconciler по-прежнему доигрывает.

View File

@@ -0,0 +1,113 @@
# ТЗ: Reconciler пропускает escalated / max-retries / blocked-needs-input задачи
Work Item ID: ORCH-060
Стадия: analysis → architecture (архитектор фиксирует механику в ADR)
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче |
|--------|---------------|
| `src/reconciler.py` | **Основное изменение.** F-1: `Reconciler._reconcile_gate_task` — добавить пред-проверки (escalated / blocked / needs-input) ДО `advance_if_gate_passed`. |
| `src/stage_engine.py` | Источник `MAX_DEVELOPER_RETRIES` (=3) и `_developer_retry_count(task_id)`. Кандидат на промоут приватного хелпера в переиспользуемый (решает архитектор). |
| `src/db.py` | Чтение состояния задачи (`get_active_tasks_for_reconcile` уже отдаёт строки `tasks`); возможный новый read-helper для retry-count, если решено не импортировать приватный из stage_engine. |
| `src/plane_sync.py` | Маппинг Plane-статусов (`PLANE_STATES`, `get_project_states`): `blocked`, `needs_input`. Источник для проверки «человеческого» статуса, если архитектор выберет проверку через Plane API. |
| `src/webhooks/gitea.py` | НЕ меняется (только справочно: точки эскалации `:280`, `:371`). |
## 2. Требуемое поведение (контракт F-1)
`Reconciler._reconcile_gate_task(task)` ДО вызова `advance_if_gate_passed(...)`
обязан вернуться (пропустить задачу, ничего не делая, не инкрементируя
`unblocked_total`, не слать нотификации), если выполнено ЛЮБОЕ из условий:
1. **Escalated по ретраям (обязательно, детерминированно, без сети):**
`developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`.
- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine` (НЕ хардкодить число).
- Источник счётчика — тот же запрос, что в `_developer_retry_count`:
`SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'`.
2. **Явный человеческий/терминальный Plane-статус:** issue в состоянии
**Blocked** или **Needs Input**.
Порядок: проверки добавляются в `_reconcile_gate_task` ПОСЛЕ существующих гардов
(`stage=='analysis'` carve-out, `get_qg_for_stage is None`, `has_active_job_for_task`,
grace) и ДО `advance_if_gate_passed`. Условие (1) — дешёвое (локальный SQL) —
проверять раньше условия (2), если (2) требует сети.
## 3. Механика проверки blocked/needs-input (выбор — за архитектором, ADR)
В таблице `tasks` НЕТ столбца статуса (`stage` всегда `development` у escalated).
Архитектор выбирает и обосновывает один из вариантов; требования к каждому:
- **Вариант A — проверка через Plane API (без миграции, предпочтительно по
инварианту ORCH-053 «схема не меняется»):** для кандидата F-1 запросить текущее
состояние issue (per-project `get_project_states` → сверка с `blocked`/`needs_input`).
Допустимо, т.к. F-1 уже делает сетевой вызов в гейте (`check_ci_green`), а
кандидатов после grace+no-active-job немного. Обязателен never-raise: ошибка
запроса → консервативно НЕ трогать задачу (skip), либо явно обоснованный фоллбэк.
- **Вариант B — локальный терминальный маркер в БД:** идемпотентная миграция
(`tasks.blocked`/`tasks.reconcile_skip`), выставляется в точках `set_issue_blocked`/
`set_issue_needs_input` и в точках эскалации `gitea.py`. Требует обоснования
нарушения инварианта «схема reconciler не меняется» и затрагивает больше точек.
> Рекомендация аналитика: условие (1) полностью закрывает зафиксированный инцидент
> (ET-013 = escalated = max retries) детерминированно и без сети — оно
> обязательно к реализации. Условие (2) — защита от автоперекрытия ручного гейта;
> минимально-инвазивный путь — Вариант A. Архитектор вправе ограничить (2)
> Вариантом A либо обосновать B.
## 4. Изменения API
Нет. Эндпоинты не добавляются и не меняются. Снимок `GET /queue` (блок `reconcile`)
по содержимому не меняется; опционально архитектор может добавить best-effort
счётчик `skipped_escalated` (необязательно, вне scope AC).
## 5. Изменения схемы БД
По умолчанию — **нет** (Вариант A). При выборе Варианта B — идемпотентная
ALTER-миграция через `_ensure_column` (как остальные в `db.init_db`),
restart-safe, безопасная на живой прод-БД; обязательна явная мотивация в ADR.
## 6. Требования к QG checks
Нет новых QG. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. Гард —
ВНЕ гейта: он решает, ЗАПУСКАТЬ ли пред-оценку гейта вообще, а не меняет вердикт
гейта.
## 7. Инварианты, которые нельзя нарушить
- **Never-raise** на единицу работы (per-task `try/except` в `reconcile_gate_once`
сохраняется; новая логика не должна бросать наружу).
- **Тишина при пропуске:** пропущенная задача не инкрементирует `unblocked_total`,
не пишет лог `разблокирована`, не шлёт Telegram.
- **Регресс F-1 happy-path:** задача с retry < лимита и не-Blocked/Needs-Input при
зелёном гейте по-прежнему доигрывается (`advance_stage` вызывается).
- **F-2** по существу не меняется: Blocked/Needs Input не входят в
{in_progress, approved, rejected} → не доигрываются (зафиксировать регресс-тестом).
- `analysis` carve-out F-1 сохраняется.
- Kill-switch'и (`reconcile_enabled`, `reconcile_plane_enabled`) работают как прежде.
## 8. Артефакты pipeline, которые должны быть созданы/обновлены
- `docs/work-items/ORCH-060/06-adr/ADR-001-*.md` — решение по механике (2) (A vs B).
- `docs/architecture/README.md` — дополнить описание F-1 («skip escalated /
blocked / needs-input»).
- `CHANGELOG.md` — запись `fix(reconciler): ...`.
- Тесты — `tests/test_reconciler.py` (расширение).
- Обновить footer `docs/architecture/README.md` (статус ORCH-060).
## 9. Точки изменения кода (конкретно)
1. `src/reconciler.py`, `_reconcile_gate_task`: после grace-проверки и до
`advance_if_gate_passed` вставить:
```python
# ORCH-060: escalated tasks (max developer retries reached) are terminal —
# they wait for a human, not the sweeper. Skip deterministically (no network).
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
return
# ORCH-060: respect an explicit human gate (Blocked / Needs Input).
if self._is_blocked_or_needs_input(task): # mechanism per ADR (Variant A/B)
return
```
2. `src/reconciler.py`: импорт `MAX_DEVELOPER_RETRIES` (и retry-count хелпера) из
`stage_engine` (или новый read-helper в `db.py`).
3. Хелпер проверки Plane-статуса (`_is_blocked_or_needs_input`) — never-raise.

View File

@@ -0,0 +1,124 @@
# Критерии приёмки: ORCH-060
Work Item ID: ORCH-060
Формат: каждый критерий — Дано / Когда / Тогда, с однозначным PASS/FAIL.
---
## AC-1 — Escalated-задача (retry == лимит) не разблокируется (главный кейс ET-013)
- **Дано:** задача на `stage='development'`, без активного job, `age >= grace`,
`check_ci_green` зелёный; в `agent_runs` ровно `MAX_DEVELOPER_RETRIES` (=3)
записей `agent='developer'`.
- **Когда:** выполняется `Reconciler.reconcile_gate_once()`.
- **Тогда:** стадия остаётся `development`; `advance_stage`/`advance_if_gate_passed`
не приводит к смене стадии; `unblocked_total == 0`; новый developer/reviewer job
не создаётся.
- **PASS:** стадия не изменилась И `unblocked_total == 0` И нет новых job.
- **FAIL:** стадия стала `review` / появился новый job / `unblocked_total > 0`.
## AC-2 — Граница: retry > лимита тоже пропускается
- **Дано:** то же, но developer-записей `> MAX_DEVELOPER_RETRIES` (например 45).
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** задача пропущена (как AC-1).
- **PASS / FAIL:** как AC-1.
## AC-3 — Регресс happy-path: retry < лимита по-прежнему доигрывается
- **Дано:** `development`, без активного job, `age >= grace`, `check_ci_green`
зелёный; developer-записей `< MAX_DEVELOPER_RETRIES` (например 0, 1 или 2).
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** задача доигрывается `development → review`; `unblocked_total == 1`;
enqueue следующего агента происходит как раньше.
- **PASS:** стадия стала `review` И `unblocked_total == 1`.
- **FAIL:** задача пропущена / стадия не изменилась.
## AC-4 — Граница ровно на лимите (==3) → skip, на (лимит1) → advance
- **Дано:** две задачи-близнеца, идентичные кроме числа developer-записей:
одна с `MAX_DEVELOPER_RETRIES`, другая с `MAX_DEVELOPER_RETRIES 1`.
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** первая пропущена (skip), вторая доиграна (advance).
- **PASS:** ровно одна из двух доиграна (та, что `1`).
- **FAIL:** обе доиграны / обе пропущены / доиграна задача на лимите.
## AC-5 — Plane-статус Blocked → пропуск
- **Дано:** задача-кандидат F-1 (stage не-терминальный, без активного job,
`age >= grace`, гейт зелёный), у которой текущий Plane-статус issue = **Blocked**;
retry < лимита (чтобы изолировать именно этот гард).
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** задача пропущена; стадия не меняется; `unblocked_total == 0`.
- **PASS:** стадия не изменилась И `unblocked_total == 0`.
- **FAIL:** задача доиграна.
## AC-6 — Plane-статус Needs Input → пропуск
- **Дано:** как AC-5, но Plane-статус = **Needs Input**.
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** задача пропущена (как AC-5).
- **PASS / FAIL:** как AC-5.
## AC-7 — Тишина при пропуске (no spam)
- **Дано:** escalated-задача (как AC-1).
- **Когда:** `reconcile_gate_once()` (один или несколько тиков).
- **Тогда:** НЕ вызывается `_note_unblock`; нет лог-строки `... разблокирована`;
нет `send_telegram`; нет `notify_qg_failure` (пропуск — раньше оценки гейта).
- **PASS:** ни одна из перечисленных нотификаций не вызвана.
- **FAIL:** вызвана любая нотификация.
## AC-8 — Никакого сетевого вызова гейта на escalated-задаче
- **Дано:** escalated-задача (как AC-1) с замоканным `check_ci_green`.
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** `check_ci_green` (через `advance_if_gate_passed`/`_run_qg`) НЕ
вызывается для этой задачи — пропуск происходит раньше.
- **PASS:** мок гейта не вызван.
- **FAIL:** мок гейта вызван.
## AC-9 — F-2 не доигрывает Blocked/Needs Input (регресс)
- **Дано:** issue в Plane-статусе Blocked или Needs Input (не входит в
{in_progress, approved, rejected}).
- **Когда:** `reconcile_plane_once()`.
- **Тогда:** ни `handle_status_start`, ни `handle_verdict` не вызываются для
этого issue; `unblocked_total == 0`.
- **PASS:** обработчики не вызваны.
- **FAIL:** вызван любой обработчик.
## AC-10 — Never-raise: ошибка проверки статуса не ломает тик
- **Дано:** проверка blocked/needs-input (Plane API в Варианте A) бросает
исключение для одной задачи; в выборке есть ещё одна валидная задача.
- **Когда:** `reconcile_gate_once()`.
- **Тогда:** тик не падает; сбойная задача консервативно НЕ трогается (skip);
остальные обрабатываются.
- **PASS:** исключение изолировано, остальные задачи обработаны.
- **FAIL:** исключение всплыло из `reconcile_gate_once`.
## AC-11 — Лимит не хардкодится
- **Дано:** код F-1-гарда.
- **Тогда:** используется `stage_engine.MAX_DEVELOPER_RETRIES`, а не литерал `3`.
- **PASS:** граница берётся из константы.
- **FAIL:** в reconciler.py появился магический `3`.
## AC-12 — Документация обновлена (golden source)
- **Дано:** PR задачи.
- **Тогда:** обновлены `docs/architecture/README.md` (описание F-1 с новым skip),
`CHANGELOG.md`, создан `06-adr/ADR-001-*.md`.
- **PASS:** все три артефакта обновлены/созданы в этом же PR.
- **FAIL:** любой отсутствует (reviewer → REQUEST_CHANGES).
## AC-13 — Регресс существующих тестов reconciler
- **Дано:** существующий `tests/test_reconciler.py` (ORCH-053).
- **Когда:** `pytest tests/test_reconciler.py -q`.
- **Тогда:** все прежние тесты зелёные (поведение happy-path/analysis/kill-switch
не сломано).
- **PASS:** 0 регрессий.
- **FAIL:** любой ранее зелёный тест упал.

View File

@@ -0,0 +1,82 @@
work_item: ORCH-060
description: >
Reconciler F-1 пропускает escalated (retry >= MAX_DEVELOPER_RETRIES) и
явно-blocked / needs-input задачи; happy-path и no-spam сохранены.
Конвенции test-фикстур — как в существующем tests/test_reconciler.py
(изолированная sqlite-БД, моки Plane/Telegram/gate). Хелпер _make_task
вставляет задачу; developer-ретраи моделируются вставкой N строк в agent_runs
(agent='developer'); зелёный CI — через _green_ci(monkeypatch).
tests:
- id: TC-01
type: unit
description: "AC-1: escalated dev-задача (ровно MAX_DEVELOPER_RETRIES developer-ранов) при зелёном CI НЕ разблокируется — стадия остаётся development, unblocked_total==0, новых job нет"
module: tests/test_reconciler.py
setup: "_make_task('development', age_s=grace+60); insert MAX_DEVELOPER_RETRIES rows agent_runs(agent='developer'); _green_ci()"
expected: PASS
- id: TC-02
type: unit
description: "AC-2: developer-ранов > MAX_DEVELOPER_RETRIES (45) → также skip"
module: tests/test_reconciler.py
expected: PASS
- id: TC-03
type: unit
description: "AC-3 (регресс happy-path): developer-ранов < MAX (0/1/2) при зелёном CI → задача доигрывается development->review, unblocked_total==1"
module: tests/test_reconciler.py
expected: PASS
- id: TC-04
type: unit
description: "AC-4: граница — задача с ровно MAX пропущена, задача с MAX-1 доиграна (ровно одна advance)"
module: tests/test_reconciler.py
expected: PASS
- id: TC-05
type: unit
description: "AC-5: задача в Plane-статусе Blocked (retry<лимита) пропущена — стадия не меняется, unblocked_total==0 (мок проверки статуса возвращает Blocked)"
module: tests/test_reconciler.py
expected: PASS
- id: TC-06
type: unit
description: "AC-6: задача в Plane-статусе Needs Input (retry<лимита) пропущена"
module: tests/test_reconciler.py
expected: PASS
- id: TC-07
type: unit
description: "AC-7 (no spam): на escalated-задаче не вызваны _note_unblock / send_telegram / notify_qg_failure; нет лог-строки 'разблокирована'"
module: tests/test_reconciler.py
expected: PASS
- id: TC-08
type: unit
description: "AC-8: на escalated-задаче мок check_ci_green НЕ вызван (skip раньше пред-оценки гейта)"
module: tests/test_reconciler.py
expected: PASS
- id: TC-09
type: unit
description: "AC-9 (регресс F-2): issue в Blocked/Needs Input не передаётся ни в handle_status_start, ни в handle_verdict при reconcile_plane_once; unblocked_total==0"
module: tests/test_reconciler.py
expected: PASS
- id: TC-10
type: unit
description: "AC-10 (never-raise): проверка blocked/needs-input бросает исключение на одной задаче → тик не падает, сбойная skip, валидная соседняя обработана"
module: tests/test_reconciler.py
expected: PASS
- id: TC-11
type: unit
description: "AC-11: граница берётся из stage_engine.MAX_DEVELOPER_RETRIES — тест с monkeypatch значения константы меняет точку отсечения (нет хардкода 3)"
module: tests/test_reconciler.py
expected: PASS
- id: TC-12
type: integration
description: "AC-13 (регресс): полный прогон tests/test_reconciler.py (ORCH-053 кейсы) — все прежние тесты зелёные"
module: tests/test_reconciler.py
expected: PASS

View File

@@ -0,0 +1,161 @@
# ADR-001: Reconciler (F-1) пропускает escalated / Blocked / Needs-Input задачи
- **Статус:** Accepted
- **Дата:** 2026-06-07
- **Задача:** ORCH-060
- **Стадия:** architecture
- **Связано:** adr-0007 (reconciler, ORCH-053) — уточняет контракт F-1;
ORCH-046 (retry-счётчик), ORCH-047 (BLOCKED-вердикт)
## Контекст
ORCH-053 ввёл F-1 (`Reconciler._reconcile_gate_task`): для каждой не-терминальной
задачи без активного job и старше grace делается read-only пред-оценка
канонического QG; зелёный → `advance_if_gate_passed`
`advance_stage(..., finished_agent=None)`.
**Дефект (инцидент ET-013, 0607.06.2026).** Задача, исчерпавшая лимит
developer-ретраев (`_developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES = 3`),
**escalated** в обработчиках `gitea.py` (`:280` CI-failure, `:371` review
REQUEST_CHANGES) выполняет ТОЛЬКО `notify_error(...)`:
- стадия НЕ меняется (остаётся `development`);
- терминального маркера в БД нет (нет столбца статуса в `tasks`);
- активного job нет.
Для F-1 такая задача **неотличима** от «застрявшей из-за потерянного webhook».
Если CI зелёный (типовой кейс: dev починил CI, но reviewer слал REQUEST_CHANGES
до лимита), каждые `reconcile_interval_s` (120с) F-1 видит зелёный `check_ci_green`
и разблокирует `development → review` → reviewer снова REQUEST_CHANGES → откат →
снова эскалация (стадия не меняется) → следующий тик снова разблокирует.
**Бесконечный цикл:** ET-013 разблокирована 10 раз за ночь, лишние запуски агентов
(токены/деньги), спам в Telegram, паразитная нагрузка общего self-hosting-инстанса.
Симметричный риск: задачу, которую человек явно перевёл в Plane-статус **Blocked**
/ **Needs Input** (ручной гейт), sweeper не должен авторазблокировать до
вмешательства человека.
## Решение
В `_reconcile_gate_task` ПОСЛЕ существующих гардов (`stage=='analysis'` carve-out,
`get_qg_for_stage is None`, `has_active_job_for_task`, grace) и ДО
`advance_if_gate_passed` добавляются два пред-гарда. Любой срабатывает → ранний
`return`: задача пропущена, гейт НЕ оценивается, `unblocked_total` не растёт,
нотификаций нет.
### Гард 1 — escalated по ретраям (детерминированный, без сети) — **обязателен**
```python
# ORCH-060: escalated tasks (max developer retries reached) are terminal —
# they wait for a human, not the sweeper. Deterministic, no network.
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
return
```
- Источник истины по retry — `agent_runs` (как у `_developer_retry_count`):
`SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'`.
- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine`**не хардкодить `3`**
(AC-11).
- Граница `>=` (на лимите — skip, на `лимит1` — advance; AC-4).
**Промоут хелпера.** `stage_engine._developer_retry_count` повышается до публичного
`developer_retry_count` (приватное имя сохраняется как алиас для существующих
внутренних call-sites). Reconciler импортирует
`MAX_DEVELOPER_RETRIES, developer_retry_count` из `stage_engine`. SQL **не
дублируется** в `db.py` — единый источник истины по подсчёту ретраев.
### Гард 2 — явный человеческий Plane-статус (Blocked / Needs Input) — **Вариант A**
```python
# ORCH-060: respect an explicit human gate (Blocked / Needs Input).
if self._is_blocked_or_needs_input(task):
return
```
Механика — **Вариант A (запрос Plane API, без миграции схемы):**
1. Новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id)
-> str | None` — GET issue-detail (тот же endpoint/headers, что
`fetch_issue_sequence_id` / `fetch_issue_fields`), возвращает uuid текущего
`state`; любая ошибка/отсутствие поля → `None`.
2. `Reconciler._is_blocked_or_needs_input(task)`:
- `repo → ProjectConfig` через `projects.get_project_by_repo(task['repo'])`;
- `pid = proj.plane_project_id`; `states = get_project_states(pid)` (кэш per-project);
- `cur = fetch_issue_state(task['plane_id' | 'plane_issue_id'], pid)`;
- вернуть `cur in {states['blocked'], states['needs_input']}`.
- **Never-raise → консервативный фоллбэк:** любая ошибка/`None`/нерезолвленный
проект → трактуем как «возможно заблокировано» → возвращаем `True` (skip).
Не-разблокировать безопаснее, чем разблокировать (AC-10).
**Порядок гардов:** Гард 1 (локальный SQL, дёшево) — ПЕРВЫМ; Гард 2 (сеть) —
вторым. Для зафиксированного инцидента (ET-013 = escalated) Гард 1 закрывает кейс
**без единого сетевого вызова**.
### Что НЕ меняется (инварианты ORCH-053)
- Схема БД — **без миграции** (Вариант A). `STAGE_TRANSITIONS` / `QG_CHECKS` —
без изменений. Гард — ВНЕ гейта: решает, ЗАПУСКАТЬ ли пред-оценку, а не меняет
вердикт.
- Never-raise на единицу работы (`reconcile_gate_once` per-task `try/except`
сохраняется; новая логика не бросает наружу).
- `analysis` carve-out, kill-switch'и (`reconcile_enabled`,
`reconcile_plane_enabled`) — как прежде.
- F-2 по существу не меняется: Blocked/Needs Input не входят в
`{in_progress, approved, rejected}` → не доигрываются (фиксируется
регресс-тестом AC-9).
### Опционально (вне scope AC, рекомендации)
- Под-флаг `reconcile_skip_blocked_enabled` (default `true`) для независимого
отключения только Гарда 2 (сетевого), по аналогии с `reconcile_plane_enabled`.
Гард 1 (локальный, безопасный) — всегда активен.
- Best-effort счётчик `skipped_escalated` в снимке `GET /queue` (наблюдаемость).
## Альтернативы
- **Вариант B — локальный терминальный маркер в БД** (`tasks.blocked` /
`tasks.reconcile_skip`, идемпотентный ALTER, выставляется в `set_issue_blocked`
/ `set_issue_needs_input` и точках эскалации `gitea.py`). **Отклонён как
primary:**
- нарушает инвариант ORCH-053 «схема reconciler не меняется» (миграция на живой
прод-БД = self-hosting-риск);
- затрагивает больше точек записи (4+: две эскалации gitea + два set_issue_*) —
выше риск рассинхрона маркера и факта;
- для зафиксированного инцидента **не нужен**: Гард 1 (retry-count) закрывает
ET-013 детерминированно и без сети.
Вариант B остаётся задокументированным будущим упрочнением, если Plane-coupling
Гарда 2 окажется болезненным (см. Последствия).
- **Подавление в самом `advance_stage` / новый терминальный вердикт гейта** —
отклонён: меняет общий критический путь; ORCH-053 уже постановил «не вызывать
advance на красном», тот же принцип «не вызывать advance на escalated».
- **Гард только по retry (без Гарда 2)** — недостаточно: не покрывает ручной
Blocked при retry<лимита; AC-5/AC-6 требуют пропуск.
## Последствия
- **Плюсы:** ET-013-петля устранена детерминированно; 0 фантомных разблокировок,
0 лишних запусков агентов, 0 спама по escalated-задачам; ручной Blocked/Needs
Input уважается; без миграции БД и без изменения реестров → минимальный
self-hosting-риск; единый источник истины по retry (промоут хелпера).
- **Минусы / плата:**
- Гард 2 вводит **per-candidate сетевой вызов** Plane на тике. Митигировано:
кандидатов после grace+no-active-job немного; `get_project_states` кэшируется;
Гард 1 отсекает escalated до сети.
- **Plane-coupling F-1:** при недоступности Plane Гард 2 фоллбэкает в skip →
F-1 во время Plane-outage не доигрывает кандидатов с retry<лимита (консерва-
тивно «не навреди»). Приемлемо: outage редок/транзиентен; escalated-кейс
(Гард 1) от Plane не зависит и продолжает работать; альтернатива
(proceed-on-error) рискует вернуть bounce при реальном Blocked. Под-флаг
`reconcile_skip_blocked_enabled` даёт ручной обход на время инцидента.
- **Self-hosting:** изменение — чистая логика sweeper'а; прод-контейнер не
рестартится/не роняется; деплой через staging (8501) по канону.
## Связи
- **adr-0007 (reconciler, ORCH-053)** — данный ADR уточняет контракт F-1
(`_reconcile_gate_task` приобретает два пред-гарда; инварианты сохранены).
- **adr-0003 (условный staging-гейт)** — образец never-raise + флага раската
(Гард 2 / `reconcile_skip_blocked_enabled`).
- **adr-0001 (реестр проектов)** — `get_project_by_repo` → `plane_project_id`
для резолва per-project статусов (Вариант A).
- ORCH-046 (retry-счётчик `agent_runs`), ORCH-047 (BLOCKED-вердикт).

View File

@@ -0,0 +1,20 @@
# Технические риски: ORCH-060
Work Item ID: ORCH-060
Стадия: architecture
| # | Риск | Вероятность | Влияние | Митигация |
|---|------|-------------|---------|-----------|
| R-1 | **Plane-coupling F-1.** Гард 2 (Вариант A) делает сетевой вызов на тике; при недоступности Plane все кандидаты с retry<лимита фоллбэкают в skip → F-1 временно не доигрывает. | Низкая (outage редок) | Среднее | Консервативный фоллбэк («не навреди»); escalated-кейс закрыт Гардом 1 без сети; под-флаг `reconcile_skip_blocked_enabled` для ручного обхода; `get_project_states` кэшируется. |
| R-2 | **Стоимость поллинга.** Per-candidate GET issue-detail каждые 120с при большом числе stuck-задач. | Низкая | Низкое | Кандидатов после grace+no-active-job мало; Гард 1 (локальный SQL) отсекает escalated до сети; вызов только для переживших Гард 1. |
| R-3 | **Промоут хелпера ломает call-sites.** `_developer_retry_count → developer_retry_count`. | Низкая | Среднее | Сохранить приватный алиас `_developer_retry_count = developer_retry_count`; grep всех вызовов перед мержем; покрыто существующими тестами stage_engine. |
| R-4 | **Неверный фоллбэк-знак Гарда 2.** Если ошибку трактовать как «не заблокировано» → возврат ET-013-bounce при реальном Blocked. | Средняя (ошибка реализации) | Высокое | ADR явно фиксирует: ошибка/None/нерезолвленный проект → `True` (skip); AC-10 проверяет never-raise+skip. |
| R-5 | **Резолв plane-issue-id из task.** В `tasks` два поля (`plane_id` / `plane_issue_id`); неверный выбор → пустой запрос. | Низкая | Низкое | Использовать тот же приоритет, что `get_task_by_plane_id` (оба поля); пустой id → фоллбэк skip. |
| R-6 | **Регресс happy-path.** Слишком широкий гард пропустит честно-застрявшие задачи (retry<лимита, не Blocked). | Низкая | Высокое | AC-3/AC-4 (граница ровно на лимите); регресс существующих тестов AC-13. |
| R-7 | **Self-hosting деплой.** Изменение работающего в проде sweeper'а. | Низкая | Высокое | Чистая логика, без миграции/рестарт-контрактов; обязательный прогон через staging (8501) перед прод-деплоем; kill-switch `reconcile_enabled`. |
## Вывод
Все риски — низкие/средние по вероятности и митигируемы в рамках выбранной
архитектуры (Вариант A, без миграции). Критичен корректный знак never-raise
фоллбэка Гарда 2 (R-4) — выделен в AC-10. Схема БД и реестры не меняются →
self-hosting-риск минимален.

View File

@@ -0,0 +1,63 @@
---
type: review
work_item_id: ORCH-060
verdict: APPROVED
version: 1
---
# Review ORCH-060
## Summary
Reviewer-проверка PR `feature/ORCH-060-reconciler-escalated-max-retri` (commit `4db8276`,
`fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1`).
Задача — устранить инцидент ET-013 (бесконечная разблокировка escalated-задачи F-1-реконсайлером).
Реализованы два пред-гарда в `Reconciler._reconcile_gate_task` строго ПОСЛЕ существующих гардов
(`analysis` carve-out → нет гейта → активный job → grace) и ДО `advance_if_gate_passed`:
- **Guard 1** (детерминированный, без сети, проверяется первым): `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`;
- **Guard 2** (Вариант A — Plane API, never-raise → консервативный skip): `_is_blocked_or_needs_input(task)`.
Реализация **полностью соответствует** ТЗ (`02-trz.md`), критериям приёмки (`03-acceptance-criteria.md`)
и ADR-001. Все 13 AC покрыты тестами (TC-01…TC-11 + sub-flag + F-2-регресс). `pytest tests/ -q`
**644 passed, 0 регрессий**; `tests/test_reconciler.py` — 27 passed.
## Соответствие ТЗ / ADR
- **Guard 1** — точка вставки, граница `>=`, источник счётчика (`agent_runs`) совпадают с ТЗ §9 и ADR §«Гард 1». ✓
- Промоут `stage_engine._developer_retry_count` → публичный `developer_retry_count`, приватный алиас сохранён, все 4 внутренних call-site (`stage_engine.py:565/613/874/950`) работают через алиас — единый источник истины, SQL не дублируется. ✓
- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine`, **хардкода `3` в `reconciler.py` нет** (grep подтверждает). ✓ (AC-11)
- **Guard 2 — Вариант A** без миграции БД: новый never-raise `plane_sync.fetch_issue_state` (тот же endpoint/headers, что `fetch_issue_sequence_id`), консервативный фоллбэк (`True`→skip) при любой ошибке/`None`/нерезолвленном проекте. Соответствует ADR §«Гард 2» и обоснованию выбора A над B. ✓
- Под-флаг `reconcile_skip_blocked_enabled` (default `true`) гасит ТОЛЬКО сетевой Guard 2; Guard 1 всегда активен. ✓
- Инварианты ORCH-053 сохранены: схема БД / `STAGE_TRANSITIONS` / `QG_CHECKS` не тронуты; never-raise на единицу работы (`reconcile_gate_once` per-task `try/except` + `_is_blocked_or_needs_input` внутренний `try/except`); тишина при пропуске (ранний `return` до `advance`, без `unblocked_total++`/лога/Telegram); `analysis` carve-out и kill-switch'и не изменены. ✓
- API не изменён (`GET /queue` без изменений по содержимому) — соответствует ТЗ §4. ✓
## Качество кода
- Docstrings на новых публичных/значимых функциях (`fetch_issue_state`, `developer_retry_count`, `_is_blocked_or_needs_input`) — содержательные, объясняют контракт never-raise и мотивацию. ✓
- Обработка Plane-формата `state` (bare uuid и `{"id": ...}`-вложение) — defensive. ✓
- Тесты содержательные (не тривиальные): граница ровно на лимите (TC-04), изоляция исключения с проверкой соседа (TC-10), отсутствие сетевого вызова гейта на escalated (TC-08), регресс F-2 (TC-09). ✓
- Self-hosting: чистая логика sweeper'а, прод-контейнер не рестартится/не роняется. ✓
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
> Замечание (P3 / информационно, не блокирует): Guard 2 делает per-candidate сетевой вызов Plane
> для ВСЕХ репо (включая не-self-hosting), а не только для `orchestrator`. Это осознанное решение
> Варианта A, явно зафиксировано в ADR §«Последствия» (митигировано: кандидатов после grace мало,
> `get_project_states` кэшируется, Guard 1 отсекает escalated до сети). Соответствует ADR — не finding.
## Документация
Обновлено в этом же PR (AC-12 — PASS):
- `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md` — создан, Accepted, полное обоснование A vs B. ✓
- `docs/architecture/README.md` — описание F-1 дополнено skip escalated/Blocked/Needs-Input; footer ORCH-060 переведён в статус «реализовано» с деталями. ✓
- `CHANGELOG.md` — запись в `### Fixed` (`fix(reconciler): ...`). ✓
- `README.md` — таблица env дополнена `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`. ✓
- `.env.example` — канонический ключ + дескриптор добавлены (правило CLAUDE.md №8). ✓
Документация = golden source: код и доку обновлены синхронно. Нарушений нет.

View File

@@ -0,0 +1,72 @@
---
type: test-report
work_item_id: ORCH-060
result: PASS
---
# Test Report — ORCH-060
Reconciler F-1 пропускает escalated (retry ≥ MAX_DEVELOPER_RETRIES) и явно
Blocked / Needs-Input задачи; happy-path и no-spam сохранены.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8)
- Ветка: `feature/ORCH-060-reconciler-escalated-max-retri` @ `55e5e96`
(фикс: `4db8276 fix(reconciler): skip escalated / Blocked / Needs-Input tasks in F-1`)
- Дата: 2026-06-07
- Review verdict: APPROVED (`12-review.md`)
## Smoke test API (прод 8500, read-only)
> `curl` отсутствует в окружении тестера — проверка выполнена через `python urllib`.
> Прод-контейнер НЕ перезапускался / не ронялся (self-hosting, CLAUDE.md §⚠️).
| Endpoint | HTTP | Ответ |
|----------|------|-------|
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | 200 | активные задачи отданы (в т.ч. ORCH-060 stage=testing) |
| `GET /queue` | 200 | counts/resilience/reconcile-блок отданы |
## Результаты (test-plan 04-test-plan.yaml → AC)
| TC ID | AC | Описание | Тест | Результат |
|-------|-----|----------|------|-----------|
| TC-01 | AC-1 | escalated == MAX_DEVELOPER_RETRIES при зелёном CI → skip | `test_tc060_01_escalated_at_limit_skipped` | PASS |
| TC-02 | AC-2 | dev-ранов > MAX → skip | `test_tc060_02_over_limit_skipped` | PASS |
| TC-03 | AC-3 | регресс happy-path: retry < MAX → advance dev→review | `test_tc060_03_under_limit_still_advances` | PASS |
| TC-04 | AC-4 | граница: ровно MAX skip, MAX1 advance (ровно одна) | `test_tc060_04_boundary_exactly_one_advances` | PASS |
| TC-05 | AC-5 | Plane-статус Blocked → skip | `test_tc060_05_blocked_skipped` | PASS |
| TC-06 | AC-6 | Plane-статус Needs Input → skip | `test_tc060_06_needs_input_skipped` | PASS |
| TC-07 | AC-7 | no spam на escalated (нет _note_unblock/telegram/qg-fail) | `test_tc060_07_escalated_no_spam` | PASS |
| TC-08 | AC-8 | escalated → мок check_ci_green НЕ вызван (skip раньше гейта) | `test_tc060_08_no_gate_call_on_escalated` | PASS |
| TC-09 | AC-9 | регресс F-2: Blocked/Needs Input не доигрывается | `test_tc060_09_f2_does_not_replay_blocked` | PASS |
| TC-10 | AC-10 | never-raise: ошибка guard2 изолирована, сосед обработан | `test_tc060_10_guard2_never_raise` | PASS |
| TC-11 | AC-11 | граница из stage_engine.MAX_DEVELOPER_RETRIES (нет хардкода 3) | `test_tc060_11_limit_from_constant` | PASS |
| — | — | под-флаг `reconcile_skip_blocked_enabled` гасит только guard2 | `test_tc060_subflag_disables_only_guard2` | PASS |
| TC-12 | AC-13 | регресс: полный прогон test_reconciler.py (ORCH-053 кейсы) | `tests/test_reconciler.py` (27 passed) | PASS |
| — | AC-12 | документация (README/ADR/CHANGELOG) — проверено reviewer'ом | — | PASS |
## Вывод pytest
Полный регресс:
```
$ python -m pytest tests/ -q
........................................................................ [ 11%]
... (644 dots) ...
.................................................................... [100%]
644 passed, 1 warning in 15.65s
```
Целевой модуль:
```
$ python -m pytest tests/test_reconciler.py -v
...
27 passed, 1 warning in 1.23s
```
(1 warning — PydanticDeprecatedSince20 в `src/config.py:4`, не связано с ORCH-060,
существующий технический долг.)
## Итог
**PASS** — все 13 критериев приёмки покрыты и зелёные, полный регресс 644/644,
целевой модуль 27/27, smoke API 3/3. Регрессий нет. Задача готова к стадии
deploy-staging.

View File

@@ -0,0 +1,80 @@
---
staging_status: FAILED
timestamp: 2026-06-07T11:57:34Z
base_url: http://localhost:8501
mode: stub
result: 8/10
work_item: ORCH-060
---
# Staging Gate Log
Staging test suite **FAILED** (exit code 1, 8/10 checks PASS).
Canonical run (ORCH-048, ADR-001) — executed INSIDE the `orchestrator-staging`
container against the live staging instance:
```
python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub
```
## Failing checks
- **C9a — Branch appears in `orchestrator-sandbox`** → FAIL (`branch=not found`).
After triggering the pipeline via `POST /webhook/plane`, no feature branch was
created in the sandbox repo within the 60s poll window.
- **C9b — Analyst job enqueued in staging queue** → FAIL. No analyst job appeared
in the staging job queue within the 30s window.
Both failures are in the E2E block (Block C): the webhook was accepted
(C8 → HTTP 200 `{'status': 'accepted'}`) and the Plane issue was created (C7 →
HTTP 201), but the pipeline did not materialise a branch or enqueue the analyst
job — the staging instance did not actually process the triggered task end-to-end.
## Passing checks (8/10)
- Block A (SMOKE): A1 /health 200, A2 /queue shape, A3 ORCH_STAGING=true.
- Block B (ACCESS): B4 Plane sandbox reachable, B5 Gitea sandbox push=true,
B6 registry isolation (sandbox present, prod ET/ORCH absent — confirms the
canonical in-container run; B6 would false-FAIL from the host).
## Verdict
Machine verdict is authoritative: exit code 1 → `staging_status: FAILED`.
Per the conditional staging gate (ORCH-35), a FAILED staging gate for the
self-hosting repo rolls the task back to `development`.
## Raw output
```
============================================================
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-07T11:55:50.247315+00:00
============================================================
[Block A] SMOKE
✓ PASS A1 GET /health → 200 status=ok [HTTP 200, body={'status': 'ok', 'service': 'orchestrator'}]
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience [HTTP 200, keys=['counts', 'max_concurrency', 'poll_interval', 'resilience', 'reconcile', 'recent']]
✓ PASS A3 ORCH_STAGING=true (not prod) [ORCH_STAGING=true]
[Block B] ACCESS
✓ PASS B4 Plane: sandbox project accessible [HTTP 200, found 5 project(s), sandbox=YES]
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true [HTTP 200, permissions={'admin': True, 'push': True, 'pull': True}]
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
[Block C] E2E (mode=stub)
C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=a05995d1-4e3c-44f7-af6f-8bd28fa6367d]
C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
✗ FAIL C9a Branch appears in orchestrator-sandbox [branch=not found]
✗ FAIL C9b Analyst job enqueued in staging queue
[CLEANUP]
✓ PASS CLEANUP: deleted Plane issue a05995d1-4e3c-44f7-af6f-8bd28fa6367d (HTTP 204)
============================================================
RESULT: 8/10 checks PASS
============================================================
__EXIT_CODE__=1
```

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: deploy-staging петля — откат на development (self-deploy)
Work Item ID: ORCH-061
## Description
TBD

View File

@@ -0,0 +1,117 @@
# 01 — BRD: BUG — deploy-staging петля (откат deploy-staging → development) для self-deploy
Work Item: **ORCH-061**
Тип: **BUG**
Приоритет: **P0**
Репозиторий: `orchestrator` (self-hosting)
Эпик-контекст: блокер **ORCH-54** (автономное внедрение self-hosting)
---
## 1. Резюме (Executive summary)
На стадии `deploy-staging` для self-hosting репозитория `orchestrator` задача
зацикливается: гейт ребра `deploy-staging → deploy` даёт FAILED, `stage_engine`
откатывает задачу `deploy-staging → development`, developer-агент перезапускается,
проходит конвейер заново, снова упирается в `deploy-staging`, снова откат — и так
по кругу (с расходом developer-ретраев и кредитов LLM), либо до исчерпания лимита
ретраев и блокировки.
Следствие: **прод-деплой self-hosting репо невозможен автономно**. Последние
ORCH-задачи (ORCH-58, ORCH-60) доводились до прода **вручную** (ручной merge PR +
ручной build-once retag + ручной `--deploy`). Это прямой блокер автономного
внедрения (эпик ORCH-54).
## 2. Бизнес-контекст и проблема
Оркестратор дорабатывает сам себя (self-hosting). Стадия `deploy-staging`
(порт 8501) — обязательная страховка перед прод-деплоем орка (ORCH-35, ADR-0003).
На этой стадии deployer гоняет `scripts/staging_check.py` против живого
staging-стенда и пишет машинный вердикт `staging_status: SUCCESS|FAILED` в
`15-staging-log.md`. Гейт `check_staging_status` читает этот вердикт; `FAILED`
→ откат на `development` (ORCH-35, `_handle_qg_failure_rollbacks`).
Подтверждены **две независимые причины** зацикливания.
### Причина №1 — ложный FAILED `check_staging_status` (контекст ORCH-58)
`staging_check.py` в sandbox-прогоне даёт **8/10 PASS, 2 ложных FAIL** на e2e-блоке
Block C:
- **C9a** — ветка не появляется в `orchestrator-sandbox` (branch not found);
- **C9b** — analyst-job не появляется в staging-очереди (`/queue → recent`).
Сопутствующая пометка suite: «Plane comment check skipped: bot-tokens not added to
SANDBOX project» — bot-аккаунты агентов (`ORCH_PLANE_BOT_*`) не добавлены членами
SANDBOX-проекта Plane (проект создан после провижининга ботов). Это **отсутствие
sandbox-настроек инфраструктуры, а не регресс кода**. Тем не менее `staging_check.py`
возвращает ненулевой exit-code → deployer пишет `staging_status: FAILED` → гейт
FAILED → откат `deploy-staging → development`.
### Причина №2 — «no changes to commit» на action-стадии (контекст ORCH-60)
Стадии деплоя по своей природе **действие, а не правка кода** (рестарт/retag), и
закономерно не порождают git-изменений в `src/`/`tests/`. Сигнал «no changes»
для action-стадии не должен трактоваться как недовыполнение работы; критерий успеха
action-стадии — успешное выполнение действия (exit0 + доказанный health/staging),
а не наличие нового коммита. Сейчас отсутствие изменений на стадии деплоя приводит
к недопродвижению задачи и откату.
### Совокупный эффект
Любая из причин по отдельности достаточна, чтобы зациклить self-deploy. Обе
проявились на реальных задачах ORCH-58 и ORCH-60, которые пришлось доводить вручную.
## 3. Цели (Goals)
- **G1.** ORCH-задача для self-hosting `orchestrator` проходит
`deploy-staging → deploy → done` **без ручного вмешательства** и **без петли**.
- **G2.** Ложный (инфраструктурный) FAIL `staging_check` в sandbox **не вызывает**
откат `deploy-staging → development`.
- **G3.** Отсутствие git-изменений на стадиях деплоя (`deploy-staging` / `deploy`)
**не трактуется** как недовыполнение и не приводит к откату.
- **G4.** Реальный регресс (настоящий провал staging-проверки или прод-деплоя)
**по-прежнему** приводит к откату `→ development` (страховка не ослабляется).
## 4. Вне области (Non-goals)
- Полная автоматизация ручного approve прод-деплоя (это ORCH-54).
- Изменение конвейера стадий (`STAGE_TRANSITIONS`), реестра гейтов как структуры,
контрактов `check_deploy_status` / `check_staging_status` frontmatter-вердиктов.
- Изменение поведения для **не**-self-hosting репозиториев (enduro-trails и пр.):
для них staging-гейт и self-deploy остаются no-op / прежними.
- Изменение схемы БД.
## 5. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Owner / оператор оркестратора | Автономный self-deploy без ручных шагов и без ночных петель. |
| Другие проекты (enduro-trails) | Их конвейер не должен быть затронут (общий инстанс, общая очередь). |
| Агенты (deployer) | Чёткий, не ложно-срабатывающий контракт стадии деплоя. |
## 6. Кандидатные направления решения (из бизнес-запроса)
Бизнес-запрос называет два направления (одно или оба); **выбор и механизм —
за архитектором (ADR)**, BRD требует лишь достижения G1G4:
- **(а)** Сделать sandbox-прогон `staging_check` честным (например, настроить
bot-токены SANDBOX Plane-проекта / починить sandbox e2e), чтобы C9a/C9b
проходили честно (10/10) и `check_staging_status` не падал ложно.
- **(б)** Отвязать продвижение стадий деплоя от git-changes для self-deploy:
успех action-стадии = exit0 + health/staging PASS, а не наличие коммита.
## 7. Бизнес-эффект / риски бездействия
- **Эффект:** разблокировка автономного внедрения self-hosting (ORCH-54);
устранение ручного труда (merge + retag + deploy) и риска ошибки при ручных шагах.
- **Риск бездействия:** каждая ORCH-задача требует ручного дотягивания до прода;
петли жгут кредиты LLM и developer-ретраи, задачи блокируются.
## 8. Допущения
- Прод-контейнер `orchestrator` (8500) обслуживает все проекты из общего инстанса —
его **нельзя** ронять/перезапускать в рамках задачи (см. CLAUDE.md, INFRA.md).
- Изменения касаются self-hosting пути (`is_self_hosting_repo` / `self_deploy_applies`);
для прочих репо поведение не меняется.
- Документация — golden source: затронутые `docs/architecture/README.md`,
`docs/operations/STAGING_CHECK.md`, `CHANGELOG.md` обновляются в том же PR.

View File

@@ -0,0 +1,145 @@
# 02 — ТЗ: устранение петли deploy-staging → development при self-deploy
Work Item: **ORCH-061** · Тип: **BUG** · Приоритет: **P0** · Репо: `orchestrator`
> Это ТЗ фиксирует **требования и контракты**, которые должна удовлетворить
> реализация. Конкретный архитектурный механизм (направление (а), (б) или оба;
> где именно разместить логику) выбирает архитектор в ADR (`06-adr/`).
> ТЗ намеренно не предписывает дизайн, но задаёт инварианты и границы изменений.
---
## 1. Затронутые модули `src/` и артефакты
Прямо относящиеся к дефекту (для контекста; точечный набор правок — за архитектором):
| Файл | Роль в дефекте |
|------|----------------|
| `scripts/staging_check.py` | e2e-suite; C9a (branch) / C9b (analyst job) дают ложный FAIL в sandbox; exit-code управляет вердиктом deployer. |
| `src/qg/checks.py``check_staging_status`, `_parse_staging_status` | гейт ребра `deploy-staging→deploy`; читает `staging_status:` из `15-staging-log.md`. |
| `src/stage_engine.py``advance_stage`, `_handle_qg_failure_rollbacks` | откат `deploy-staging→development` при FAILED (ветка `agent=="deployer" and qg=="check_staging_status"`). |
| `src/agents/launcher.py``_handle_completion`/`_try_advance_stage` | пост-ран git-commit; лог «no changes to commit»; обработка deployer-стадий. |
| `src/self_deploy.py` | Phase A/B/C исполняемого self-deploy (контекст продвижения `deploy`). |
| `src/config.py` | место для kill-switch/настроек нового поведения (если потребуется). |
| `.openclaw/agents/deployer.md` | инструкция deployer о написании вердикта; обновить при смене контракта. |
| `docs/operations/STAGING_CHECK.md`, `docs/architecture/README.md`, `CHANGELOG.md` | golden-source документация (обновить в том же PR). |
## 2. Функциональные требования
### FR-1 — Нет петли на корректном self-deploy
Для self-hosting `orchestrator`, при корректном состоянии (реальный pipeline в
порядке, staging-стенд здоров), задача проходит `deploy-staging → deploy → done`
**без отката** `deploy-staging → development` и **без ручного вмешательства**.
### FR-2 — Ложный (инфраструктурный) FAIL не вызывает откат
Ложное падение `staging_check` в sandbox, вызванное **исключительно** отсутствием
sandbox-настроек (например, C9a/C9b при ненастроенных bot-токенах SANDBOX), не
приводит к `staging_status: FAILED` → откату. Должно быть реализовано одним из
способов (выбор — ADR):
- **(а)** sandbox-инфраструктура приведена в состояние, при котором C9a/C9b
проходят честно (10/10); и/или
- **(б)** вердикт staging-гейта перестаёт зависеть от заведомо инфраструктурных
(не пайплайновых) проверок — например, осознанный allowlist/threshold
«известных sandbox-инфра» проверок, отделённый от реальных pipeline-проверок.
> Любой механизм по FR-2 **обязан** сохранить FR-4 (реальный провал ловится).
### FR-3 — «no changes» на action-стадии не есть недовыполнение
На стадиях деплоя (`deploy-staging`, `deploy`) для self-deploy отсутствие
git-изменений (`no changes to commit`) **не** трактуется как недовыполнение и
**не** приводит к откату/блокировке. Критерий успеха action-стадии = успешный
exit агента/хука + доказанный health/staging-вердикт, а **не** наличие нового
коммита.
### FR-4 — Реальный регресс по-прежнему откатывается (страховка цела)
- Настоящий провал реальных pipeline-проверок staging → `staging_status: FAILED`
→ откат `deploy-staging → development` (как сейчас).
- Настоящий провал прод-деплоя (`deploy_status: FAILED`, БАГ-8) → откат
`deploy → development` (как сейчас).
- Ослабления страховки быть не должно: «зелёный по умолчанию» при недоступности
проверок запрещён (fail-closed для реальных проверок сохраняется).
### FR-5 — Условность self-hosting сохранена
Изменения активны **только** для self-hosting пути
(`is_self_hosting_repo` / `self_deploy_applies`). Для прочих репозиториев
поведение `check_staging_status` (no-op N/A) и стадии деплоя — **без изменений**.
### FR-6 — Управляемость (kill-switch)
Любое новое поведение (толерантность к инфра-FAIL и/или отвязка от git-changes)
закрыто отдельным флагом конфигурации (по образцу `merge_gate_enabled`,
`image_freshness_enabled`, `self_deploy_enabled`), с безопасным дефолтом и
возможностью мгновенно вернуть прежнее поведение без передеплоя кода-логики.
### FR-7 — Наблюдаемость
Срабатывание нового поведения (например, «staging_check: проигнорирован
инфра-FAIL C9a/C9b» или «action-стадия: no-changes ожидаемо») логируется явной
строкой и при необходимости отражается в Plane-комментарии/Telegram, чтобы
оператор отличал «реальный зелёный» от «зелёного с допущением».
## 3. Изменения API
API эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) — **без изменений**.
Допускается расширение снапшота `GET /queue` диагностическим полем (опционально,
по решению архитектора) — без удаления/переименования существующих ключей.
## 4. Изменения схемы БД
**Нет.** Схема (`events`, `tasks`, `agent_runs`, `jobs`) не меняется. Любое
restart-safe состояние (если потребуется) — через существующие паттерны
(sentinel-файлы / поля `jobs.task_content`), без миграций.
## 5. Контракты, которые НЕЛЬЗЯ менять
- `STAGE_TRANSITIONS` (порядок и состав стадий) и `get_previous_stage`.
- Состав/семантика `QG_CHECKS` как реестра; frontmatter-контракты
`staging_status:` (`15-staging-log.md`) и `deploy_status:` (`14-deploy-log.md`) —
читаются ТОЛЬКО из YAML-frontmatter, значения `SUCCESS|FAILED`.
- Откатные контракты БАГ-8 (`deploy→development`) и ORCH-35
(`deploy-staging→development`) для **реальных** провалов.
- Контракт exit-code хука деплоя (`0/1/2`) и `map_exit_code_to_status`.
- Поведение для не-self-hosting репозиториев.
## 6. Требования к новым/изменённым QG checks
- Если выбран механизм толерантности (FR-2 вариант б), он реализуется **внутри**
существующего пути `check_staging_status` / staging-вердикта (не новая стадия),
по образцу условности ORCH-35; контракт «never-raise» сохраняется.
- Любая новая проверка/под-чек регистрируется в `QG_CHECKS` и покрывается
снапшот-тестом реестра (`tests/test_qg_registry_snapshot.py`).
## 7. Требования к staging_check.py (если затрагивается)
- Если выбран механизм классификации проверок (FR-2 вариант б через suite),
e2e-проверки, заведомо зависящие от sandbox-инфраструктуры (C9a/C9b и связанные),
должны быть **отличимы** (по метке/категории) от реальных pipeline-проверок,
чтобы вердикт и/или exit-code мог их учитывать осознанно. Прежний дефолтный
режим (`stub`/`full-real`) и существующие проверки A/B сохраняются.
- Никакого «всегда 0»: реальный провал реальных проверок обязан давать ненулевой
exit-code / FAIL-категорию.
## 8. Требования к pipeline-артефактам
- Стадия деплоя по-прежнему производит машинный вердикт-артефакт
(`15-staging-log.md` / `14-deploy-log.md`) с корректным frontmatter.
- Артефакты, обновляемые по pipeline в этом PR: `docs/architecture/README.md`
(раздел про staging-гейт/self-deploy — отметить ORCH-061),
`docs/operations/STAGING_CHECK.md` (поведение C9a/C9b и/или sandbox-настройка),
`CHANGELOG.md`, при изменении контракта — `.openclaw/agents/deployer.md`.
- ADR: `docs/work-items/ORCH-061/06-adr/ADR-001-*.md` (решение по направлению/механизму).
## 9. Нефункциональные требования
- **Безопасность self-hosting:** реализация НЕ перезапускает/не роняет прод 8500
в рамках задачи; сборки/recreate — только staging (8501).
- **Идемпотентность / restart-safe:** новое поведение переживает рестарт инстанса.
- **never-raise:** дефект-исправляющая логика не должна пробрасывать исключения в
`advance_stage` (по образцу merge-gate / image-freshness).
- **Обратная совместимость:** при выключенном флаге (FR-6) — прежнее поведение 1:1.
- **Тестируемость:** «чистая» вердикт-логика выделяется так, чтобы покрываться
unit-тестами без live staging/docker.
## 10. Зависимости и связанные задачи
- ORCH-35 (условный staging-гейт, ADR-0003), ORCH-36 (исполняемый self-deploy,
ADR-0007), ORCH-58 (провенанс staging-образа), ORCH-60 (skip escalated/Blocked).
- Блокирует: ORCH-54 (автономное внедрение).

View File

@@ -0,0 +1,90 @@
# 03 — Критерии приёмки: ORCH-061
Work Item: **ORCH-061** · Тип: **BUG** · Приоритет: **P0**
Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Критерии outcome-ориентированы
(не предписывают механизм); реализация может удовлетворить FR-2 направлением (а), (б) или обоими.
---
## AC-1 — Автономный проход self-deploy без петли (главный критерий)
- **PASS:** для self-hosting `orchestrator` задача в состоянии `deploy-staging`
при здоровом стенде и корректном pipeline продвигается `deploy-staging → deploy`
(далее по штатному approve → `done`) **без** отката на `development` и **без**
ручного вмешательства в шаги staging/merge/retag/deploy.
- **FAIL:** наблюдается хотя бы один автоматический откат `deploy-staging → development`
при отсутствии реального регресса, либо для прохода требуется ручной шаг.
## AC-2 — Ложный инфраструктурный FAIL не откатывает
- **PASS:** прогон, где **единственные** падения — заведомо sandbox-инфраструктурные
(C9a branch-not-found / C9b analyst-job-not-in-queue при ненастроенных bot-токенах
SANDBOX), а все реальные pipeline-проверки зелёные, приводит к
`staging_status: SUCCESS` (или эквивалентному «не-FAILED») → **нет** отката.
- **FAIL:** такой прогон даёт `staging_status: FAILED` → откат `deploy-staging → development`.
## AC-3 — Реальный провал staging по-прежнему откатывает (страховка цела)
- **PASS:** прогон с провалом **реальной** pipeline-проверки (не инфра-исключение)
даёт `staging_status: FAILED` → откат `deploy-staging → development` +
`set_issue_blocked`/нотификации (как сейчас, ORCH-35).
- **FAIL:** реальный провал staging проходит как успех / задача доходит до `deploy`.
## AC-4 — «no changes to commit» на action-стадии не есть недовыполнение
- **PASS:** на стадиях `deploy-staging`/`deploy` для self-deploy отсутствие
git-изменений не вызывает откат/блокировку; продвижение определяется успешным
exit + health/staging-вердиктом.
- **FAIL:** отсутствие коммита на стадии деплоя приводит к откату/недопродвижению.
## AC-5 — Реальный провал прод-деплоя по-прежнему откатывает (БАГ-8 цел)
- **PASS:** `deploy_status: FAILED` (exit-code хука ≠ 0) → откат `deploy → development`
+ `set_issue_blocked` + release merge-lease + clear deploy-state (как сейчас).
- **FAIL:** провал прод-деплоя проходит как `done`.
## AC-6 — Условность self-hosting сохранена
- **PASS:** для не-self-hosting репо (`is_self_hosting_repo == False`)
`check_staging_status` остаётся `(True, "Staging gate N/A …")`, стадия деплоя
работает как прежде; поведение этих репо байт-в-байт не изменилось.
- **FAIL:** изменилось поведение для не-self-hosting репозиториев.
## AC-7 — Kill-switch возвращает прежнее поведение
- **PASS:** при выключенном флаге нового поведения (FR-6) система ведёт себя 1:1
как до ORCH-061 (включая прежний откат на инфра-FAIL, если флаг выключен).
- **FAIL:** новое поведение невозможно отключить / выключение не восстанавливает старое.
## AC-8 — Контракты не сломаны
- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter-контракты
`staging_status:`/`deploy_status:` (только YAML, `SUCCESS|FAILED`), exit-code хука
(0/1/2) и `map_exit_code_to_status` — без регресса; снапшот-тест реестра гейтов зелёный.
- **FAIL:** изменены контракты стадий/гейтов/вердиктов или сломан снапшот реестра.
## AC-9 — Схема БД не меняется
- **PASS:** нет миграций; `events`/`tasks`/`agent_runs`/`jobs` без изменений схемы.
- **FAIL:** добавлена/изменена колонка/таблица.
## AC-10 — never-raise
- **PASS:** новая логика в пути `advance_stage`/staging-вердикта при любой внутренней
ошибке (docker/ssh/io/парсинг) даёт безопасный детерминированный вердикт и не
пробрасывает исключение в `advance_stage`.
- **FAIL:** исключение из новой логики всплывает в `advance_stage`/останавливает конвейер.
## AC-11 — Наблюдаемость
- **PASS:** срабатывание нового поведения (игнор инфра-FAIL / ожидаемые no-changes)
даёт явную лог-строку (и при необходимости коммент/Telegram), позволяющую отличить
«честно зелёный» от «зелёного с допущением».
- **FAIL:** новое поведение срабатывает молча, неотличимо от честного зелёного.
## AC-12 — Безопасность self-hosting
- **PASS:** реализация не перезапускает/не роняет прод-контейнер 8500 в рамках
задачи; любые сборки/recreate — только staging (8501).
- **FAIL:** код пути задачи рестартит/собирает прод 8500.
## AC-13 — Документация обновлена (golden source)
- **PASS:** в том же PR обновлены `docs/architecture/README.md`,
`docs/operations/STAGING_CHECK.md` (поведение C9a/C9b и/или sandbox-настройка),
`CHANGELOG.md`, и (при смене контракта) `.openclaw/agents/deployer.md`; заведён
ADR `docs/work-items/ORCH-061/06-adr/ADR-001-*.md`.
- **FAIL:** функционал изменён без обновления документации/ADR.
## AC-14 — Регрессионные тесты зелёные
- **PASS:** `pytest tests/ -q` проходит полностью; новые тесты из `04-test-plan.yaml`
присутствуют и зелёные; существующие staging/deploy/qg/stage_engine тесты не упали.
- **FAIL:** любой тест из плана отсутствует или красный.

View File

@@ -0,0 +1,147 @@
work_item: ORCH-061
title: "BUG: deploy-staging петля — откат на development (self-deploy)"
description: >
План тестов на устранение зацикливания deploy-staging -> development для
self-hosting orchestrator. Покрывает обе подтверждённые причины: (1) ложный
FAILED check_staging_status из-за заведомо инфраструктурных C9a/C9b в sandbox;
(2) трактовку "no changes to commit" на action-стадии как недовыполнения.
Тесты outcome-ориентированы и не предписывают механизм: часть кейсов помечена
как mechanism-dependent (а=sandbox-инфра честно, б=толерантность/отвязка) —
финальный набор подтверждает архитектор в ADR; реализуются тесты под выбранный
механизм. Инвариант страховки (реальный регресс откатывает) и условность
self-hosting проверяются ВСЕГДА.
tests:
# --- Главный сценарий: нет петли ----------------------------------------
- id: TC-01
type: unit
description: >
Корректный self-deploy: при staging_status SUCCESS и пройденном merge/freshness
sub-gate advance_stage(deploy-staging, finished_agent=deployer) продвигает к
deploy (Phase A approval-pending), НЕ откатывает на development. (AC-1)
module: tests/test_stage_engine.py
expected: PASS
- id: TC-02
type: unit
description: >
Регресс-страховка ORCH-35: реальный провал реальной pipeline-проверки ->
staging_status FAILED -> advance_stage откатывает deploy-staging -> development
+ set_issue_blocked. (AC-3)
module: tests/test_stage_engine.py
expected: PASS
# --- Причина №1: ложный инфраструктурный FAIL ---------------------------
- id: TC-03
type: unit
description: >
Классификация проверок staging_check: проверки, заведомо зависящие от
sandbox-инфраструктуры (C9a/C9b), отличимы (метка/категория) от реальных
pipeline-проверок. Чистая логика классификации/вердикта тестируется без
live staging/docker. (AC-2, mechanism-dependent: вариант б)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-04
type: unit
description: >
Вердикт-логика: все реальные проверки PASS, падают ТОЛЬКО известные
sandbox-инфра проверки (C9a/C9b) -> итог не-FAILED (нет ложного отката).
(AC-2)
module: tests/test_qg_checks.py
expected: PASS
- id: TC-05
type: unit
description: >
Вердикт-логика: падает хотя бы одна РЕАЛЬНАЯ pipeline-проверка (помимо инфра)
-> итог FAILED (страховка не ослаблена, fail-closed). (AC-3)
module: tests/test_qg_checks.py
expected: PASS
# --- Причина №2: no changes на action-стадии ----------------------------
- id: TC-06
type: unit
description: >
На action-стадии (deploy-staging/deploy) для self-deploy отсутствие
git-изменений ("no changes to commit") НЕ приводит к откату/недопродвижению;
продвижение определяется exit + вердиктом, а не наличием коммита. (AC-4)
module: tests/test_launcher.py
expected: PASS
- id: TC-07
type: unit
description: >
На code-стадии (development) отсутствие изменений всё ещё обрабатывается
прежним образом (нет ложного "успеха" там, где код должен был измениться) —
изменение FR-3 не протекает на не-action стадии. (AC-4, regression-guard)
module: tests/test_launcher.py
expected: PASS
# --- Условность self-hosting --------------------------------------------
- id: TC-08
type: unit
description: >
Для не-self-hosting репо check_staging_status остаётся (True, "Staging gate
N/A …") и новое поведение НЕ активируется; поведение этих репо неизменно.
(AC-6, FR-5)
module: tests/test_qg.py
expected: PASS
# --- Kill-switch / обратная совместимость -------------------------------
- id: TC-09
type: unit
description: >
При выключенном флаге нового поведения (FR-6) система ведёт себя 1:1 как до
ORCH-061: инфра-FAIL снова приводит к FAILED/откату. Дефолт флага безопасен.
(AC-7)
module: tests/test_config.py
expected: PASS
# --- БАГ-8: реальный провал прод-деплоя ----------------------------------
- id: TC-10
type: unit
description: >
deploy_status FAILED (exit-code хука != 0) -> откат deploy -> development +
set_issue_blocked + release merge-lease + clear deploy-state (БАГ-8 не сломан).
(AC-5)
module: tests/test_deploy_rollback.py
expected: PASS
# --- Контракты / реестр / never-raise -----------------------------------
- id: TC-11
type: unit
description: >
Снапшот реестра QG_CHECKS и STAGE_TRANSITIONS не изменён неожиданно;
frontmatter-контракты staging_status/deploy_status (SUCCESS|FAILED, только
YAML) сохранены. (AC-8)
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-12
type: unit
description: >
never-raise: новая логика staging-вердикта/advance при внутренней ошибке
(io/парсинг/docker/ssh) возвращает безопасный детерминированный вердикт и не
пробрасывает исключение в advance_stage. (AC-10)
module: tests/test_stage_engine.py
expected: PASS
# --- Интеграционный сквозной сценарий ------------------------------------
- id: TC-13
type: integration
description: >
Сквозной self-deploy на тестовой БД: задача deploy-staging при здоровом
стенде с инфра-only недочётами проходит deploy-staging -> deploy (Phase A) ->
(approve) -> deploy финализация SUCCESS -> done, БЕЗ единого отката на
development в логе переходов. (AC-1, AC-4)
module: tests/test_stage_engine.py
expected: PASS
- id: TC-14
type: integration
description: >
Наблюдаемость: при срабатывании нового поведения (игнор инфра-FAIL /
ожидаемые no-changes) присутствует явная лог-строка/диагностика, отличающая
"честно зелёный" от "зелёного с допущением". (AC-11)
module: tests/test_stage_engine.py
expected: PASS

View File

@@ -0,0 +1,222 @@
# ADR-001 — Толерантность staging-вердикта к инфра-FAIL + инвариант «no-changes на action-стадии»
- **Статус:** Accepted
- **Дата:** 2026-06-07
- **Задача:** ORCH-061 (BUG, P0) · Репо: `orchestrator` (self-hosting)
- **Связи:** ORCH-35/adr-0003 (условный staging-гейт), ORCH-36/adr-0007 (исполняемый self-deploy), ORCH-58/adr-0008 (провенанс staging-образа), ORCH-43/adr-0006 (merge-gate); блокирует ORCH-54.
- **Сквозной ADR:** [adr-0009-staging-infra-tolerance](../../../architecture/adr/adr-0009-staging-infra-tolerance.md)
---
## Контекст
На стадии `deploy-staging` self-hosting `orchestrator` зацикливается:
`check_staging_status` даёт FAILED → `_handle_qg_failure_rollbacks` откатывает
`deploy-staging → development` → developer перезапускается → конвейер заново →
снова `deploy-staging` → снова FAILED. Петля жжёт developer-ретраи и LLM-кредиты,
а прод-деплой орка приходится доводить вручную (ORCH-58, ORCH-60). Это прямой
блокер автономного внедрения (ORCH-54).
Подтверждены две независимые причины (BRD §2):
**Причина №1 — ложный FAILED.** `scripts/staging_check.py` в sandbox даёт
8/10 PASS, 2 ложных FAIL на e2e-блоке C:
- **C9a** — ветка не появляется в `orchestrator-sandbox`;
- **C9b** — analyst-job не появляется в staging-очереди.
Оба завязаны на отсутствие sandbox-настроек (bot-аккаунты `ORCH_PLANE_BOT_*` не
добавлены членами SANDBOX-проекта — проект создан после провижининга ботов). Это
**отсутствие инфраструктуры sandbox, а не регресс кода**. Но `staging_check.py`
суммирует `all_ok = passed == total` и делает `sys.exit(1)` при любом FAIL →
deployer пишет `staging_status: FAILED` → откат.
**Причина №2 — «no changes to commit» на action-стадии.** Стадии деплоя по природе
действие (рестарт/retag), а не правка `src/`. Отсутствие git-изменений не должно
трактоваться как недовыполнение; критерий успеха action-стадии — exit0 +
health/staging-вердикт, а не наличие коммита.
### Что есть сейчас в коде (точки дефекта)
- `scripts/staging_check.py`: `Results.summary()``all_ok = passed == total`;
`main()``sys.exit(0 if all_ok else 1)`. Все проверки равнозначны — инфра-FAIL
неотличим от регресса.
- `src/qg/checks.py``check_staging_status` / `_parse_staging_status`: читает
`staging_status:` (SUCCESS|FAILED) из `15-staging-log.md`. Условный (ORCH-35):
для не-self репо → `(True, "Staging gate N/A …")`.
- `src/stage_engine.py``_handle_qg_failure_rollbacks`: ветка
`agent=="deployer" and qg=="check_staging_status"` → откат на `development`.
- `src/agents/launcher.py``_monitor_agent`: ветка «no changes to commit» (строка
~583) **уже** просто логирует и идёт в `_try_advance_stage` (НЕ откатывает).
## Рассмотренные направления (BRD §6)
- **(а) Починить sandbox-инфру** — добавить bot-токены SANDBOX, чтобы C9a/C9b
проходили честно (10/10).
- *Минусы:* хрупко (зависит от членства ботов в Plane-проекте, поддерживается
руками вне кода); не предотвращает структурно будущие инфра-only FAIL;
автономный self-deploy-таск не может надёжно выполнить Plane-admin действия сам.
Не закрывает Причину №1 на уровне инварианта.
- **(б) Отвязать вердикт от заведомо инфраструктурных проверок** — классифицировать
проверки suite и сделать вердикт толерантным к инфра-FAIL, сохранив fail-closed
для реальных проверок.
- *Плюсы:* структурно, юнит-тестируемо (чистая вердикт-логика), управляемо
(kill-switch), наблюдаемо (FR-7); сохраняет страховку (FR-4) по построению.
## Решение
Выбран механизм **(б)** как основной, с явной фиксацией инварианта по Причине №2.
Направление (а) переведено в **необязательное hardening** (см. `07-infra-requirements.md`):
с (б) оно перестаёт быть блокером.
### 1. Классификация проверок + толерантный вердикт (Причина №1, FR-2/FR-4)
Новый **leaf-модуль `src/staging_verdict.py`** — чистая логика, без I/O, контракт
**never-raise**, только stdlib (импортируем и из orchestrator, и из
`staging_check.py`, который уже импортирует `src.*` внутри контейнера — паттерн B6/ORCH-048):
```
REAL = "real" # реальная pipeline-проверка
SANDBOX_INFRA = "sandbox_infra" # заведомо зависит от sandbox-инфры
# Узкий allowlist известных инфра-проверок (по префиксу метки):
SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"})
def classify_check(label: str) -> str:
"""SANDBOX_INFRA если метка начинается с известного инфра-префикса, иначе REAL.
Never-raise: на любом непонятном вводе → REAL (консервативно, fail-closed)."""
def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict:
"""items: список (label, passed: bool, category: str).
real_failed = [REAL-проверки с passed=False]
infra_failed = [SANDBOX_INFRA-проверки с passed=False]
- real_failed непусто -> FAILED, exit 1 (страховка)
- infra_failed непусто и infra_tolerant -> SUCCESS, exit 0 (waived)
- infra_failed непусто и НЕ infra_tolerant -> FAILED, exit 1 (legacy strict)
- иначе -> SUCCESS, exit 0
Never-raise: на битом вводе → консервативный FAILED."""
```
`StagingVerdict` несёт `status` (`"SUCCESS"|"FAILED"`), `exit_code` (`0|1`),
`waived` (список заваиверенных меток) и `summary` (человекочитаемая строка).
**Ключевой инвариант страховки (FR-4):** любая упавшая REAL-проверка ⇒ exit 1 ⇒
FAILED ⇒ откат. В частности C7 (создать issue) и C8 (триггер `/webhook/plane`) —
REAL. Waiver применяется к C9a/C9b **только** когда все REAL-проверки (включая
C7/C8) зелёные. Вход в конвейер по-прежнему валидируется C7/C8; C9a/C9b проверяют
лишь downstream-артефакты, которым нужна sandbox-инфра. Так blast-radius waiver'а
сведён к двум именованным проверкам.
### 2. Правки `scripts/staging_check.py`
- `Results.add(label, passed, detail="", category=None)` — при `category is None`
авто-классификация через `staging_verdict.classify_check(label)`; хранит категорию
в элементе.
- `Results.summary()` печатает разбивку по категориям (REAL / SANDBOX_INFRA).
- `main()`:
- резолвит флаг толерантности `_resolve_tolerance()` (см. ниже);
- `verdict = compute_staging_verdict(results.items, infra_tolerant)`;
- при `verdict.waived` печатает явную строку
`INFRA-WAIVED: <labels> (known sandbox-infra; real checks green)` (FR-7);
- `sys.exit(verdict.exit_code)`.
- `_resolve_tolerance()`: читает `settings.staging_infra_tolerance_enabled` (через
`from src.config import settings` — тот же паттерн, что B6). На ошибке импорта →
**strict (False)** (fail-safe: не вайвить при нечитаемом конфиге) + warning.
Опциональный CLI-флаг `--strict` принудительно выключает толерантность для ручных
«честных» прогонов.
Прежние режимы (`--mode stub|full-real`) и проверки A/B/C7/C8 — без изменений.
«Всегда 0» исключено: упавшая REAL-проверка всегда даёт exit 1 (TRZ §7).
### 3. Kill-switch (FR-6, AC-7)
`src/config.py`:
```python
# ORCH-061: толерантность staging-вердикта к заведомо инфраструктурным FAIL
# (C9a/C9b) в sandbox. True -> упавшие ТОЛЬКО sandbox-инфра проверки вайверятся
# (real-проверки fail-closed). False -> 1:1 прежнее строгое поведение (любой FAIL
# -> staging_status FAILED -> откат). Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED.
staging_infra_tolerance_enabled: bool = True
```
Дефолт **True** (как `merge_gate_enabled` / `image_freshness_enabled` /
`self_deploy_enabled`): инвариант страховки (FR-4) держится независимо от флага —
реальные провалы всё равно fail-closed; флаг существует, чтобы мгновенно вернуть
legacy-строгость без передеплоя кода. Флаг живёт в `.env.staging` контейнера
(`ORCH_` prefix), поэтому достижим скриптом внутри `orchestrator-staging`.
`False` → suite строгий → 1:1 поведение до ORCH-061 (AC-7).
### 4. Что НЕ меняется (контракты, AC-8)
- `check_staging_status` / `_parse_staging_status`**без изменений**: читают
`staging_status:` (только YAML, `SUCCESS|FAILED`). Толерантность реализована
ДО артефакта (в exit-code suite → вердикт deployer), внутри существующего пути
staging-вердикта, не отдельной стадией (TRZ §6).
- **Новый QG-чек НЕ добавляется** → реестр `QG_CHECKS` и снапшот-тест
(`tests/test_qg_registry_snapshot.py`) неизменны (AC-8 / TC-11).
- `STAGE_TRANSITIONS`, `get_previous_stage`, exit-code хука деплоя (0/1/2),
`map_exit_code_to_status`, `check_deploy_status`, БАГ-8 — без изменений.
- Условность self-hosting (AC-6): `staging_check.py` канонически бежит только для
`orchestrator`; `check_staging_status` для не-self репо остаётся
`(True, "Staging gate N/A …")`. Поведение прочих репо байт-в-байт неизменно.
### 5. Инвариант «no-changes на action-стадии» (Причина №2, FR-3/AC-4)
`launcher._monitor_agent` **уже** не откатывает на «no changes to commit» (просто
логирует и идёт в `_try_advance_stage`; продвижение определяется гейтом). ORCH-061:
- **Фиксируем инвариант** как покрытый тестами контракт: на `deploy-staging`/`deploy`
для self-deploy продвижение определяется exit0 + гейт-вердиктом, НИКОГДА наличием
коммита (TC-06).
- **Наблюдаемость (FR-7/AC-11):** в ветке «no changes» логировать явную строку,
отличающую action-стадию (ожидаемо: артефакт-вердикт, не обязательно код) от
code-стадии. Резолв стадии задачи по `(repo, branch)`; при
`stage ∈ {deploy-staging, deploy}` и `self_deploy.self_deploy_applies(repo)`
`staging/deploy: no code changes (expected on action stage)`.
- **Regression-guard (TC-07):** на `development` (code-стадия) поведение «no changes»
неизменно — изменение FR-3 не протекает на не-action стадию.
Изменение минимальное (self-hosting safety, AC-12): не трогает прод-контейнер 8500,
сборки/recreate — только staging (8501).
## Затронутые файлы (для developer)
| Файл | Изменение |
|------|-----------|
| `src/staging_verdict.py` | **новый** leaf-модуль: `classify_check`, `compute_staging_verdict`, `StagingVerdict` (pure, never-raise). |
| `scripts/staging_check.py` | категории в `Results`, вердикт через `staging_verdict`, INFRA-WAIVED-лог, `--strict`. |
| `src/config.py` | флаг `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`). |
| `src/agents/launcher.py` | observability-лог action-stage no-changes (без смены логики продвижения). |
| `.openclaw/agents/deployer.md` | уточнение: exit0 может включать «infra-waived»; контракт `staging_status:` SUCCESS\|FAILED неизменен. |
| `docs/operations/STAGING_CHECK.md` | поведение C9a/C9b, флаг, INFRA-WAIVED, `--strict`. |
| `docs/architecture/README.md` | пометка ORCH-061 в разделе staging-гейта (уже внесена архитектором). |
| `CHANGELOG.md` | запись ORCH-061. |
| `tests/` | TC-01…TC-14 (см. `04-test-plan.yaml`). |
## Последствия
**Плюсы**
- Петля устранена структурно: ложный инфра-FAIL → SUCCESS (waived) → нет отката (G1/G2).
- Страховка цела: любая реальная pipeline-проверка fail-closed → FAILED → откат (G4/FR-4).
- Чистая вердикт-логика юнит-тестируема без live staging/docker (NFR-тестируемость).
- Контракты гейтов/стадий/вердиктов/реестра не тронуты (AC-8); схема БД не меняется (AC-9).
- Мгновенный откат к legacy через kill-switch (AC-7).
- Разблокирует автономный self-deploy (ORCH-54).
**Минусы / ограничения**
- C9a/C9b теперь могут заваиверить **реальный** даунстрим-регресс именно в создании
ветки / постановке analyst-job (узкий риск). Митигировано: waiver только когда C7/C8
и все прочие REAL зелёные; allowlist жёстко = {C9a, C9b}; INFRA-WAIVED логируется и
виден оператору. См. `10-tech-risks.md` (R-1).
- Толерантность скрывает «нездоровье sandbox» как зелёное-с-допущением; отличимо
только по INFRA-WAIVED-логу/комментарию (наблюдаемость обязательна, FR-7).
- Honest 10/10 в sandbox (направление а) остаётся желательным hardening, но не блокером.
## Альтернативы (отклонены)
- **Только (а) — починить sandbox-инфру:** хрупко, не структурно, вне автономной
досягаемости таска. Оставлено как опциональное hardening.
- **«Зелёный по умолчанию» при недоступности проверок:** запрещён FR-4 (fail-closed).
- **Новый QG-чек `check_staging_infra_tolerant`:** избыточно — менял бы реестр
`QG_CHECKS` и снапшот; толерантность лучше живёт в suite/вердикте до артефакта.
- **Толерантность внутри `check_staging_status` через структурный артефакт:**
потребовал бы сменить контракт `15-staging-log.md` и научить deployer писать
per-check категории — больше движущихся частей; отклонено в пользу решения в suite.

View File

@@ -0,0 +1,37 @@
# 07 — Требования к инфраструктуре: ORCH-061
Work Item: **ORCH-061** · Репо: `orchestrator`
Топология/контейнеры/порты **не меняются** (TRZ §3, §9). Self-hosting-безопасность
сохранена: прод-контейнер `orchestrator` (8500) не перезапускается/не роняется в
рамках задачи; любые сборки/recreate — только staging (8501). См.
`docs/operations/INFRA.md`.
## IR-1 — Конфиг-флаг (kill-switch)
Новый флаг `staging_infra_tolerance_enabled` (env
`ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`).
- Должен присутствовать в окружении контейнера **`orchestrator-staging`**
(`.env.staging`), т.к. `scripts/staging_check.py` читает его через
`src.config.settings` при каноническом запуске `docker exec` внутри стенда.
- Для прод-инстанса (`.env`) флаг безвреден (на прод-пути staging-suite не
исполняется), но рекомендуется держать значения консистентными.
- `false` → мгновенный возврат к строгому (legacy) поведению без передеплоя кода.
- Канон секретов/env: значения в `.env`/`.env.staging` на хосте, в гит НЕ
коммитятся; задокументировать ключ в `.env.example` (канон ORCH-9).
## IR-2 — Опциональное hardening sandbox (направление «а», НЕ блокер)
Первопричина ложных C9a/C9b — bot-аккаунты агентов (`ORCH_PLANE_BOT_*`) не добавлены
членами Plane-проекта **SANDBOX** (`8c5a3025-…`), созданного после провижининга
ботов. С выбранным механизмом (б) это перестаёт блокировать конвейер, но честный
10/10 в sandbox желателен:
- Добавить bot-аккаунты агентов членами SANDBOX-проекта в Plane (даст честный
C9b: коммент analyst'а перестанет получать 403; и устранит инфра-причину C9a/C9b).
- Действие — ручное (Plane-admin), вне автоматической досягаемости таска; выполняется
оператором при возможности. После него C9a/C9b проходят честно и waiver не нужен.
- Это hardening, а не требование приёмки ORCH-061 (приёмка — на механизме «б»).
## IR-3 — Без новой инфраструктуры
Новые сервисы/порты/тома/сетевые правила/cron — **не требуются**. Никаких
изменений в `docker-compose.yml`, образах, реестре проектов.

View File

@@ -0,0 +1,20 @@
# 08 — Требования к данным / схеме БД: ORCH-061
Work Item: **ORCH-061** · Репо: `orchestrator`
## DR-1 — Схема БД не меняется (AC-9)
Никаких миграций. Таблицы `events`, `tasks`, `agent_runs`, `jobs` — без изменений
колонок/индексов/таблиц.
## DR-2 — Никакого нового персистентного состояния
Решение (ADR-001) — чистая вердикт-логика (`src/staging_verdict.py`) + конфиг-флаг +
правка exit-code suite. Состояние конвейера не вводится:
- толерантность вычисляется на лету при прогоне `staging_check.py`;
- restart-safe-состояние не требуется (вердикт фиксируется в существующем артефакте
`15-staging-log.md` через прежний контракт `staging_status: SUCCESS|FAILED`).
## DR-3 — Артефакт-контракт неизменен
`15-staging-log.md` по-прежнему несёт frontmatter `staging_status: SUCCESS|FAILED`
(только YAML). `14-deploy-log.md` (`deploy_status:`) — без изменений. Гейты читают
ТОЛЬКО frontmatter. Толерантность реализована ДО записи артефакта (на уровне
exit-code suite → вердикт deployer), поэтому формат и парсинг артефактов не трогаются.

View File

@@ -0,0 +1,25 @@
# 10 — Технические риски: ORCH-061
Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting)
| # | Риск | Вероятн. | Влияние | Митигация |
|---|------|----------|---------|-----------|
| **R-1** | Waiver C9a/C9b маскирует **реальный** регресс именно в создании ветки / постановке analyst-job (ложно-зелёный staging). | Низкая | Высокое | Allowlist жёстко `{C9a, C9b}`; waiver применяется ТОЛЬКО когда ВСЕ REAL-проверки зелёные, включая C7 (создать issue) и C8 (триггер `/webhook/plane`) — вход в конвейер всегда валидируется реально. `INFRA-WAIVED`-строка в логе/комменте делает допущение видимым (FR-7). Honest 10/10 (IR-2) убирает риск совсем. |
| **R-2** | Ослабление страховки: реальный pipeline-FAIL пройдёт как SUCCESS. | Низкая | Критич. | Инвариант `compute_staging_verdict`: любая упавшая REAL → exit1 → FAILED → откат (FR-4/AC-3/TC-05). Покрыто юнит-тестом отдельным кейсом. |
| **R-3** | Флаг не достигает скрипта (читается не из того env) → толерантность «молча» не работает или, наоборот, не выключается. | Средняя | Среднее | Скрипт читает `settings.staging_infra_tolerance_enabled` через `from src.config import settings` — тот же канал, что B6/ORCH-048 (внутри `orchestrator-staging`, env `.env.staging`). На ошибке импорта — fail-safe в strict (False) + warning. Документировать ключ в `.env.staging`/`.env.example` (IR-1). Тест kill-switch (TC-09). |
| **R-4** | Классификатор ошибочно пометит REAL-проверку как SANDBOX_INFRA (расширение allowlist в будущем). | Низкая | Высокое | `classify_check` — узкий префиксный allowlist; добавление новой инфра-метки требует осознанного PR + теста (TC-03). По умолчанию неизвестная метка → REAL (консервативно). |
| **R-5** | Регресс совместимости: изменение exit-code suite ломает другие потребители (deploy-хук, ручные прогоны). | Низкая | Среднее | Exit-code семантика сохранена для honest-прогонов (всё PASS → 0; реальный FAIL → 1). Меняется лишь трактовка «только инфра-FAIL» (теперь 0 при толерантности). Deployer-маппинг exit0→SUCCESS/≠0→FAILED не меняется; deployer.md уточняется. `--strict` даёт ручной honest-режим. |
| **R-6** | never-raise нарушен: исключение из `staging_verdict`/классификатора. | Низкая | Среднее | `src/staging_verdict.py` — pure, без I/O; контракт never-raise (на битом вводе → консервативный FAILED). Логика вне пути `advance_stage` (исполняется в subprocess suite), поэтому в конвейер исключение структурно не попадает (AC-10). |
| **R-7** | FR-3: правка no-changes протекает на code-стадию (`development`) и маскирует «developer ничего не сделал». | Низкая | Среднее | Observability-строка ограничена `stage ∈ {deploy-staging, deploy}` и `self_deploy_applies(repo)`; логика продвижения launcher не меняется. Regression-guard TC-07. |
| **R-8** | Self-hosting: правки случайно затронут прод 8500 / не-self репо. | Низкая | Критич. | Изменения только на self-deploy-пути и в suite (бежит лишь для `orchestrator`-staging). `check_staging_status` для не-self репо неизменно `(True, N/A)` (AC-6/TC-08). Сборки/recreate — только 8501. Прод 8500 не трогается (AC-12). |
## Контрактные инварианты (не нарушать)
- `STAGE_TRANSITIONS`, `get_previous_stage` — без изменений.
- Реестр `QG_CHECKS` — без изменений; новый QG-чек НЕ вводится (снапшот-тест зелёный, TC-11).
- Frontmatter `staging_status:` / `deploy_status:` — только YAML, `SUCCESS|FAILED`.
- Exit-code хука деплоя (0/1/2) и `map_exit_code_to_status` — без изменений.
- БАГ-8 (`deploy → development`) и ORCH-35 (`deploy-staging → development`) для
**реальных** провалов — сохранены.
- Схема БД — без миграций.
# ci-rerun 2026-06-07T13:08:38Z after disk cleanup
# ci-rerun gitea-restarted 2026-06-07T13:14:14Z

View File

@@ -0,0 +1,88 @@
---
type: review
work_item_id: ORCH-061
verdict: APPROVED
version: 1
---
# Review ORCH-061
## Summary
Исправление петли `deploy-staging → development` при self-hosting self-deploy.
Реализовано Direction (б) из ADR-001: классификация staging-проверок на `REAL`
(fail-closed) и `SANDBOX_INFRA` (узкий allowlist `{C9a, C9b}`, waivable) +
толерантный-но-fail-closed вердикт.
Реализация **полностью соответствует ТЗ (02-trz.md), критериям приёмки
(03-acceptance-criteria.md) и ADR-001**. Все контракты сохранены, документация
обновлена в том же PR, тесты зелёные.
Проверено по осям:
- **Соответствие ТЗ:** FR-1…FR-7 закрыты. Новый leaf-модуль
`src/staging_verdict.py` (stdlib-only, never-raise), флаг
`staging_infra_tolerance_enabled` (kill-switch, default True), observability
через `INFRA-WAIVED:`/`VERDICT:` и `action_stage_no_changes_note`.
- **Соответствие ADR-001:** механизм, allowlist `{C9a, C9b}`, fail-closed для
REAL, waiver только когда все REAL (вкл. C7/C8) зелёные, `--strict`,
`_resolve_tolerance` (fail-safe → strict при нечитаемом конфиге) — реализовано
ровно как в «Решении» ADR. Затронутые файлы совпадают с таблицей ADR.
- **Контракты (AC-8):** `src/qg/checks.py` (`check_staging_status`/
`_parse_staging_status`), `src/stages.py` (`STAGE_TRANSITIONS`, `QG_CHECKS`)
**не изменены** (подтверждено `git diff`). Толерантность живёт в suite ДО
записи артефакта; новый QG-чек не вводится; реестр-снапшот цел.
- **Схема БД (AC-9):** миграций нет, флаг — только конфиг.
- **never-raise (AC-10):** `compute_staging_verdict`/`classify_check`/
`_coerce_item`/`action_stage_no_changes_note` ловят всё и деградируют в
консервативный FAILED/None. Покрыто TC-12.
- **Условность self-hosting / страховка (AC-3/AC-5/AC-6):** rollback на реальном
FAIL сохранён (`tests/test_stage_engine.py` TestStaging*), поведение не-self
репо неизменно.
- **Тесты (AC-14):** `pytest tests/ -q`**670 passed**. ORCH-061 покрытие:
TC-04 (infra waived → SUCCESS), TC-05 (REAL fail → FAILED), TC-09 (strict),
TC-12 (garbage never-raise), TC-06/TC-07 (action-stage no-changes note),
non-self репо.
- **Безопасность self-hosting (AC-12):** код задачи не трогает прод 8500;
сборки/recreate — вне пути этой логики.
Примечание по диффу: при просмотре `git diff main...HEAD` появлялись файлы
ORCH-060 (reconciler, plane_sync, config reconcile-флаги). Это артефакт
**устаревшего локального ref `main`**`origin/main` уже содержит ORCH-060
(merge `d4c6cc0`, PR #60). Истинный `git diff origin/main...HEAD` — чистый
ORCH-061. Бандлинга чужого work-item нет.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- [ ] **Стрэй-файлы агентного скрэтча закоммичены в репо:** `.task.md`,
`.task-arch.md`, `.task-dev.md` (хэндофф-файлы стадий analysis/architecture/
development) попали в коммит и не покрыты `.gitignore`. Это засоряет репо и
будет повторяться каждый прогон. Рекомендация: удалить из индекса и добавить
`.task*.md` в `.gitignore`. Не функциональный дефект — на корректность
ORCH-061 не влияет.
## Документация
Обновлена в том же PR (golden source, AC-13) — соответствует требованию CLAUDE.md:
- `docs/architecture/README.md` — раздел staging-гейта помечен ORCH-061 +
статус в футере.
- `docs/architecture/adr/adr-0009-staging-infra-tolerance.md` — сквозной ADR
заведён; `adr/README.md` обновлён.
- `docs/operations/STAGING_CHECK.md` — поведение C9a/C9b, флаг, INFRA-WAIVED,
`--strict`.
- `.openclaw/agents/deployer.md` — уточнён контракт exit0/INFRA-WAIVED (контракт
`staging_status: SUCCESS|FAILED` неизменён).
- `.env.example``ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (канон, секреты не
коммитятся).
- `CHANGELOG.md` — запись ORCH-061.
- ADR per-work-item `docs/work-items/ORCH-061/06-adr/ADR-001-*.md` — присутствует.
Документация полная и точная; расхождений с кодом не выявлено.

View File

@@ -0,0 +1,85 @@
---
type: test-report
work_item_id: ORCH-061
result: PASS
---
# Test Report — ORCH-061
BUG: устранение петли `deploy-staging → development` при self-hosting self-deploy.
Реализован Direction (б) из ADR-001: классификация staging-проверок на `REAL`
(fail-closed) и `SANDBOX_INFRA` (allowlist `{C9a, C9b}`, waivable) + толерантный,
но fail-closed вердикт (`src/staging_verdict.py`), kill-switch
`staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-07T13:19Z
- Ветка: `feature/ORCH-061-bug-deploy-staging-development`
- Review verdict: APPROVED (12-review.md)
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| GET /health | HTTP 200 `{"status":"ok","service":"orchestrator"}` |
| GET /status | HTTP 200 (ORCH-061 в стадии `testing`) |
| GET /queue | HTTP 200 (counts/resilience/reconcile present) |
> Прод-контейнер 8500 не перезапускался и не трогался (self-hosting safety, AC-12).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | Корректный self-deploy: staging SUCCESS → advance к deploy, без отката | `test_stage_engine.py::test_tc01_healthy_self_deploy_advances_no_rollback` | PASS |
| TC-02 | Страховка ORCH-35: реальный FAIL → откат deploy-staging→development | `test_stage_engine.py::test_tc02_real_staging_failed_rolls_back` | PASS |
| TC-03 | Классификация REAL vs SANDBOX_INFRA (C9a/C9b отличимы) | `test_staging_check_b6.py::test_tc03_classify_infra_checks` (+ records/override/strict) | PASS |
| TC-04 | Падают только C9a/C9b → итог не-FAILED (нет ложного отката) | `test_qg_checks.py::test_tc04_only_infra_failures_waived_to_success` | PASS |
| TC-05 | Падает реальная pipeline-проверка → FAILED (fail-closed) | `test_qg_checks.py::test_tc05_any_real_failure_fails_closed` (+ `_even_alone`) | PASS |
| TC-06 | no-changes на action-стадии (deploy-staging/deploy) не есть недовыполнение | `test_launcher.py::test_tc06_deploy_staging_self_deploy_returns_note` / `test_tc06_deploy_self_deploy_returns_note` | PASS |
| TC-07 | regression-guard: на code-стадии (development) поведение прежнее | `test_launcher.py::test_tc07_development_stage_returns_none` | PASS |
| TC-08 | Не-self-hosting репо: check_staging_status остаётся (True, "N/A …") | `test_qg.py` (no-op N/A) | PASS |
| TC-09 | Kill-switch выкл → 1:1 прежнее строгое поведение, безопасный дефолт | `test_qg_checks.py::test_tc09_infra_failure_strict_mode_fails_closed` + `test_config.py::test_staging_infra_tolerance_*` | PASS |
| TC-10 | БАГ-8: deploy_status FAILED → откат deploy→development | `test_deploy_rollback.py` | PASS |
| TC-11 | Снапшот QG_CHECKS / STAGE_TRANSITIONS не изменён; frontmatter-контракты целы | `test_qg_registry_snapshot.py` | PASS |
| TC-12 | never-raise: вердикт-логика при мусоре → безопасный детерминированный FAILED | `test_qg_checks.py::test_tc12_compute_verdict_never_raises_on_garbage` + `test_stage_engine.py::test_tc12_retry_and_rollback_behavior_unchanged` | PASS |
| TC-13 | Сквозной self-deploy: deploy-staging→deploy→done без единого отката | `test_stage_engine.py::test_tc13_end_to_end_self_deploy_no_single_rollback` | PASS |
| TC-14 | Наблюдаемость: «зелёный с допущением» отличим от честного зелёного | `test_stage_engine.py::test_tc14_waived_green_distinguishable_from_honest_green` | PASS |
Все 14 TC присутствуют и зелёные.
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
| AC | Критерий | Покрытие | Статус |
|----|----------|----------|--------|
| AC-1 | Проход self-deploy без петли | TC-01, TC-13 | PASS |
| AC-2 | Инфра-FAIL (C9a/C9b) не откатывает | TC-03, TC-04 | PASS |
| AC-3 | Реальный провал staging откатывает | TC-02, TC-05 | PASS |
| AC-4 | no-changes на action-стадии ≠ недовыполнение | TC-06, TC-07 | PASS |
| AC-5 | БАГ-8: провал прод-деплоя откатывает | TC-10 | PASS |
| AC-6 | Условность self-hosting сохранена | TC-08 | PASS |
| AC-7 | Kill-switch возвращает прежнее поведение | TC-09 | PASS |
| AC-8 | Контракты не сломаны (реестр/frontmatter/exit-code) | TC-11 | PASS |
| AC-9 | Схема БД не меняется | миграций нет (флаг — конфиг) | PASS |
| AC-10 | never-raise | TC-12 | PASS |
| AC-11 | Наблюдаемость (INFRA-WAIVED / waived list) | TC-14 | PASS |
| AC-12 | Безопасность self-hosting (прод 8500 не трогается) | smoke + код пути | PASS |
| AC-13 | Документация обновлена (golden source) | подтверждено в 12-review.md | PASS |
| AC-14 | Регрессионные тесты зелёные | `pytest tests/ -q` → 670 passed | PASS |
## Вывод pytest
```
$ python -m pytest tests/ -v --tb=short
...
======================= 670 passed, 1 warning in 12.15s ========================
```
Единственный warning — PydanticDeprecatedSince20 (class-based Config в `src/config.py`),
не относится к ORCH-061, существовал ранее.
## Итог
**PASS** — полный регресс зелёный (670 passed, 0 failed), все 14 TC из плана и все 14
критериев приёмки выполнены. Страховка цела (реальный регресс staging и БАГ-8
откатывают), условность self-hosting сохранена, kill-switch работает, never-raise
покрыт. Smoke API prod — 200, прод-контейнер не затронут.
Задача готова к переходу на стадию **deploy-staging**.

View File

@@ -0,0 +1,68 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T13:27:06+00:00
base_url: http://localhost:8501
---
# Staging Gate Log — ORCH-061
Staging test suite completed against the live `orchestrator-staging` stand (8501).
**Verdict: SUCCESS (exit 0)** — all REAL pipeline checks green; the two known
sandbox-infra checks (C9a/C9b) were FAILED-but-**waived** by the ORCH-061
infra-tolerance logic. This is exactly the behaviour this work item ships.
## Observability — INFRA-WAIVED
```
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
```
## Result breakdown
```
RESULT: 8/10 checks PASS
REAL failed : none
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
tolerance: staging_infra_tolerance_enabled=True
```
| Check | Category | Result |
|-------|----------|--------|
| A1 GET /health → 200 status=ok | REAL | PASS |
| A2 GET /queue → 200 counts/max_concurrency/resilience | REAL | PASS |
| A3 ORCH_STAGING=true (not prod) | REAL | PASS |
| B4 Plane: sandbox project accessible | REAL | PASS |
| B5 Gitea: orchestrator-sandbox accessible, push=true | REAL | PASS |
| B6 Registry: sandbox present, prod ET/ORCH absent | REAL | PASS |
| C7 Create issue in Plane SANDBOX | REAL | PASS |
| C8 Trigger pipeline via /webhook/plane | REAL | PASS |
| C9a Branch appears in orchestrator-sandbox | SANDBOX_INFRA | FAIL (waived) |
| C9b Analyst job enqueued in staging queue | SANDBOX_INFRA | FAIL (waived) |
C9a/C9b fail because the SANDBOX bot accounts are not yet members of the Plane
sandbox project, so steps 6+ of the pipeline are unreachable **in the sandbox**
an infrastructure limitation, not a pipeline regression (see
`docs/operations/STAGING_CHECK.md`). All REAL checks (incl. C7/C8) are green, so
the waiver applies and the gate advances.
## Run note (self-hosting bootstrap)
The canonical bind-mounted script path (`/repos/orchestrator/scripts/staging_check.py`)
and the running `orchestrator-staging` image both predate ORCH-061 (no
`src/staging_verdict.py`, tolerance flag absent), because ORCH-061 modifies the
staging gate itself. To produce a faithful verdict for the **validated commit**,
the gate was executed from the validated worktree inside the staging container:
```
docker exec orchestrator-staging \
env PYTHONPATH=<worktree>:/app \
python3 <worktree>/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
`PYTHONPATH=<worktree>:/app` keeps B6's registry read sourced from the running
staging instance's own env (sandbox-only registry — ORCH-048/ADR-001), while
loading the shipped `staging_verdict` logic and `staging_infra_tolerance_enabled`
config. This exercises the live staging endpoints AND the exact verdict logic
being shipped. EXEC EXIT CODE: 0.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: zombie jobs + merge-lease залип (процесс умер, статус running)
Work Item ID: ORCH-065
## Description
TBD

View File

@@ -0,0 +1,103 @@
# BRD — ORCH-065: zombie jobs + залипший merge-lease
Work Item ID: ORCH-065
Тип: BUG (P0)
Репозиторий: orchestrator (self-hosting)
Эпик: блокер ORCH-54 (полностью автономный self-deploy)
## 1. Контекст и проблема
Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
`max_concurrency=1` для self-hosting), обслуживающий несколько проектов. Финальная
автономность self-deploy упирается в два связанных класса отказов, оба сводящиеся
к «процесс умер/завершился, а состояние осталось захваченным навсегда»:
### Проблема A — zombie jobs (строка `jobs` навсегда `running`)
Агент (deployer/developer/reviewer) завершается **или умирает** (краш, OOM,
рестарт контейнера в ходе self-deploy, гибель monitor-потока), но строка в таблице
`jobs` остаётся в статусе `running`. Финализация статуса job выполняется **только**
в `_monitor_agent``_finalize_job` внутри того же процесса; если этот поток/процесс
не доживает до финализации — job «зомбирован».
- Единственная имеющаяся защита — `requeue_running_jobs()` в `main.lifespan`,
срабатывающая **исключительно на старте процесса**. Зомби, возникший **без**
рестарта (умер дочерний процесс/monitor-поток, а сервис жив), не реанимируется
никогда.
- При `max_concurrency=1` одна зомби-строка `running` блокирует claim всех
последующих job (`count_running_jobs() >= max_concurrency` → claim не происходит)
**встаёт конвейер всех проектов**.
### Проблема B — залипший merge-lease
Merge-gate (ORCH-043) берёт файловый lease `<repos_dir>/.merge-lease-<repo>.json`
ПЕРЕД rebase+re-test и держит его до фактического merge PR в `main`. Если процесс
умирает на финальном merge **с зажатым lease**:
- Реклейм lease реализован **лениво и только по возрасту** (`age >=
merge_lock_timeout_s`) и **только в момент `acquire_merge_lease` другой задачей**.
Проактивного освобождения (на старте / периодически) нет; **liveness держателя по
pid не проверяется** (хотя `pid` в lease пишется).
- Пострадавшая задача сама re-drive не получает: merge не финализируется → задача
висит, lease мешает чужим merge до истечения TTL.
### Проблема C — неидемпотентная финализация merge
Если rebase+re-test прошли зелёно (ветка догнана и проверена), но процесс умер до
завершения слияния PR — повторного «докатывания» merge нет. Задача застревает в
полу-выполненном состоянии, хотя вся дорогая работа (rebase+re-test) уже сделана.
## 2. Бизнес-последствия
- **Это ПОСЛЕДНЯЯ ручная точка автономного деплоя.** Без фикса ни одна self-hosting
задача не доезжает до прода без оператора (cancel zombie + ручной merge PR +
ручной `--deploy`).
- Прямой блокер эпика ORCH-54.
- Доказанные инциденты (07.06): ORCH-58/60/61/21 — каждый раз после успешного
deployer-прохода job оставался `running`; jobs **236/239/242/254** — зомби,
прод-merge/deploy доводились вручную.
- Групповой риск: зомби в общей очереди при concurrency=1 останавливает конвейер
enduro-trails и всех прочих проектов.
## 3. Цель
Сделать так, чтобы **смерть процесса/потока на любой стадии (включая self-restart
во время deploy) НЕ оставляла навсегда захваченных ресурсов** — ни строки `jobs` в
`running`, ни merge-lease. Конвейер должен самовосстанавливаться без оператора, при
этом сохраняя все инварианты self-hosting (не ронять прод-контейнер, не трогать
`main`, fail-closed на реальных ошибках).
## 4. Объём (Scope)
### В объёме
1. **Job-reaper** — фоновый watchdog (паттерн `reconciler`/`queue_worker`),
детектирующий «мёртвый» `running`-job и приводящий его строку в корректный
терминальный/повторный статус (`done`/`failed`/`queued`) детерминированно,
без LLM. Restart-safe и работающий **без** рестарта процесса.
2. **Проактивный реклейм stale merge-lease** — освобождение lease, чей держатель
мёртв (pid не жив) ИЛИ возраст превысил TTL — на старте и периодически (reaper/
reconciler), а не только лениво при чужом `acquire`.
3. **Идемпотентная финализация merge** — если rebase+re-test зелёные, но merge не
состоялся, операция повторяется/докатывается без потери уже сделанной работы.
### Вне объёма
- Переход на внешний брокер очередей / смену схемы блокировок merge на БД-lock.
- Полный авто-approve деплоя (ORCH-54) — отдельная задача; здесь только снятие
технического блокера.
- Изменение конвейера стадий (`STAGE_TRANSITIONS`) и реестра гейтов как контрактов.
## 5. Заинтересованные стороны
- Owner оркестратора (self-hosting автономность).
- Все проекты на общем инстансе (enduro-trails и пр.) — страдают от блокировки
общей очереди.
## 6. Допущения и ограничения
- `max_concurrency=1` для self-hosting сохраняется.
- Self-hosting safety (CLAUDE.md): нельзя ронять/рестартить прод-контейнер в рамках
задачи; нельзя пушить/форс-пушить `main`; реклейм lease не должен прерывать
легитимно работающий merge.
- Никаких ложных реанимаций: живой, но долгий job не должен помечаться зомби
(нужен порог/грейс «N тиков» + проверка реальной смерти, а не просто долготы).
- Контракт **never-raise** для всей новой фоновой логики (как у reconciler/merge_gate).
- Kill-switch на каждый новый механизм (как `reconcile_enabled` / `merge_gate_enabled`).
## 7. Критерий успеха (бизнес-уровень)
После фикса воспроизводимый сценарий «успешный deployer-проход + смерть процесса/
self-restart» НЕ оставляет зомби-job и зажатого lease: задача либо корректно
доезжает до `done` сама, либо откатывается по штатному контракту — **без участия
оператора**. Регресс-тест на jobs-зомби и stale-lease зелёный.

View File

@@ -0,0 +1,170 @@
# ТЗ — ORCH-065: job-reaper + stale merge-lease reclaim + идемпотентный merge
Work Item ID: ORCH-065
Базируется на: 01-brd.md
Примечание архитектору: ТЗ фиксирует ТРЕБОВАНИЯ и кандидатные модули. Выбор
конкретной реализации (новый модуль vs расширение reconciler; колонка `jobs.pid`
vs эвристика на `agent_runs`) — за стадией architecture (ADR). Если какая-либо
часть ТЗ окажется нереализуемой/спорной — вернуть в Анализ, не комментировать
задним числом.
## 0. Текущее состояние (факты из кода)
| Место | Факт |
|-------|------|
| `src/queue_worker.py` `_drain_once` | claim не происходит, пока `count_running_jobs() >= max_concurrency`. Одна зомби-строка `running` при concurrency=1 блокирует всю очередь. |
| `src/agents/launcher.py` `_monitor_agent``_finalize_job` | статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО в этом monitor-потоке. Смерть потока/процесса до финализации ⇒ job навсегда `running`. |
| `src/main.py` (lifespan, строки ~55-61) | `requeue_running_jobs()` вызывается ТОЛЬКО при старте процесса. |
| `src/db.py` `requeue_running_jobs` | flip всех `running``queued`. Без рестарта не запускается. |
| `src/db.py` таблица `jobs` | колонки `pid`/`heartbeat` НЕТ; есть `run_id`, `started_at`, `attempts`, `max_attempts`. |
| `src/merge_gate.py` `acquire_merge_lease` | реклейм stale lease (age `>= merge_lock_timeout_s`) и corrupt — ТОЛЬКО лениво в момент `acquire`. Lease пишет `pid`, но liveness по pid нигде не проверяется. |
| `src/merge_gate.py` `release_merge_lease` | holder-aware (по `branch`), идемпотентен. Вызовы: `webhooks/gitea.py:380` (PR-merged), `stage_engine.py:352/740/876/952`, `qg/checks.py:683/691/697`. |
| `src/qg/checks.py` `check_branch_mergeable` | при SUCCESS lease ДЕРЖИТСЯ до фактического merge PR. Если процесс умрёт после этого — lease зажат. |
| `src/reconciler.py` | паттерн-образец фонового daemon-потока (never-raise, kill-switch, observability в `/queue`). |
## 1. Задействованные модули `src/`
- `src/db.py` — новые job-запросы для reaper (выборка stale running-job, атомарный
reap). Возможна lightweight-миграция (см. §3).
- **Job-reaper** — НОВЫЙ модуль (кандидат `src/job_reaper.py`) ИЛИ расширение
`src/reconciler.py`. Решение — за архитектором; ТЗ требует daemon-поток по образцу
`reconciler` (never-raise, `_stop`-Event, старт/стоп в `main.lifespan`, снимок в
`/queue`).
- `src/merge_gate.py` — функция проактивного реклейма stale/dead lease (по pid-
liveness + по TTL); helper проверки liveness pid; helper идемпотентной
финализации merge.
- `src/main.py` — старт/стоп нового daemon-потока в `lifespan` (после `worker.start()`
/ `reconciler.start()`, симметрично остановка перед `worker.stop()`); вызов
стартового реклейма stale-lease рядом с `requeue_running_jobs()`.
- `src/config.py` — новые настройки/флаги (см. §5).
- `src/main.py` `GET /queue` — блок наблюдаемости reaper (образец `reconcile`/
`post_deploy`).
## 2. Функциональные требования
### FR-1. Job-reaper (Проблема A)
- Фоновый поток периодически (`reaper_interval_s`) сканирует строки `jobs` в статусе
`running`.
- Для каждого `running`-job определяет, **жив ли его исполнитель**. «Мёртвым» job
считается, когда выполнено и устойчиво (см. FR-1.3) хотя бы одно из:
- процесс агента (по pid/run_id) не существует, а финализация не произошла;
- `agent_runs` строки run_id имеет `finished_at`/`exit_code` (процесс реально
завершился), но `jobs.status` всё ещё `running` (monitor умер между записью
exit_code и `_finalize_job`);
- job висит `running` дольше предохранительного потолка
`reaper_max_running_s` (заведомо больше любого легитимного `agent_timeout` +
grace) — backstop на случай, когда liveness определить нельзя.
- FR-1.2 Действие при подтверждённой смерти:
- если есть достоверный успешный исход (`agent_runs.exit_code == 0`) — довести job
к корректному завершению **через тот же контракт**, что `_finalize_job`
(включая, при необходимости, повторную попытку auto-advance) — НЕ дублировать
переход, если он уже произошёл (идемпотентность через `has_active_job_for_task`
/ проверку стадии);
- если исход неуспешный/неизвестен и бюджет попыток не исчерпан
(`attempts < max_attempts`) — `queued` (повторная постановка), как делает
`requeue_running_jobs`;
- если бюджет исчерпан — `failed` + Telegram-алерт.
- FR-1.3 **Анти-ложноположительность.** Job помечается зомби только после
устойчивого подтверждения смерти: процесс мёртв на протяжении `reaper_dead_ticks`
последовательных тиков (≥2) ИЛИ превышен `reaper_max_running_s`. Живой долгий
агент (в пределах своего `agent_timeout`) НИКОГДА не реапится.
- FR-1.4 Работает **без рестарта** процесса (главное отличие от существующего
`requeue_running_jobs`).
- FR-1.5 Restart-safe: после рестарта поведение корректно совмещается со стартовым
`requeue_running_jobs()` (нет двойной обработки одной строки; атомарность reap-
UPDATE с guard по `status='running'`, как в `claim_next_job`).
### FR-2. Проактивный реклейм stale/dead merge-lease (Проблема B)
- FR-2.1 На старте процесса (рядом с `requeue_running_jobs()` в `lifespan`) и
периодически в фоновом потоке: для каждого репо с merge-gate проверить lease и
освободить его, если держатель **мёртв** или lease **просрочен**.
- FR-2.2 «Держатель мёртв» = pid из lease не существует в системе (liveness-проба,
напр. `os.kill(pid, 0)` с обработкой `ProcessLookupError`/`PermissionError`),
при условии что pid принадлежит этому хосту/неймспейсу. «Просрочен» = `age >=
merge_lock_timeout_s` (существующий TTL-контракт сохраняется).
- FR-2.3 Реклейм **holder-aware и безопасен**: НЕ освобождать lease, чей держатель
жив и в пределах TTL (защита легитимного merge). Логировать `warning` при каждом
реклейме (наблюдаемость, как сейчас в `acquire_merge_lease`).
- FR-2.4 Условность как ORCH-35/43: реально только для self-hosting/`merge_gate_repos`;
прочие репо — no-op.
- FR-2.5 Контракт **never-raise**; любая ошибка реклейма не должна валить поток.
### FR-3. Идемпотентная финализация merge (Проблема C)
- FR-3.1 Если ветка прошла rebase+re-test (догнана до `origin/main` и зелёная), но
merge PR не состоялся из-за смерти процесса — система должна **докатить/повторить**
merge без повторного прогона дорогих шагов, когда это безопасно.
- FR-3.2 Финализация merge должна быть **идемпотентной**: повторный вызов при уже
слитом PR — no-op (определять по состоянию PR в Gitea и/или по
`branch_is_behind_main`/состоянию `main`), без ошибки и без второго слияния.
- FR-3.3 Восстановление re-drive обеспечивается штатными механизмами (reaper
довёл job до `queued` → повторный проход стадии `deploy`/merge-gate; либо
reconciler доигрывает переход). Дублирующая логика merge НЕ создаётся — переиспользуются
существующие пути (`check_branch_mergeable` / deployer-merge).
- FR-3.4 При повторе lease берётся заново (идемпотентный re-acquire «held by self»
по branch уже поддержан в `acquire_merge_lease`).
### FR-4. Наблюдаемость
- FR-4.1 Блок `reaper` в `GET /queue`: enabled, interval, last_run_ts, reaped_total,
last_reaped (job_id/agent), lease_reclaimed_total (best-effort, как у reconciler).
- FR-4.2 Каждый reap и каждый lease-reclaim — `logger.warning` с идентификаторами
(job_id, run_id, pid, repo, branch).
- FR-4.3 При reap→`failed` и при lease-reclaim — Telegram (как существующие алерты).
## 3. Изменения схемы БД
- Текущая `jobs` НЕ содержит `pid`. Для надёжной pid-liveness job-reaper'у, скорее
всего, потребуется **lightweight-миграция**: добавить `jobs.pid INTEGER` (через
`_ensure_column`, идемпотентно, безопасно на live prod DB — паттерн уже
применяется в `db.py`). pid проставляется в `_spawn` рядом с `run_id`/`started_at`.
- **Альтернатива без миграции** (на усмотрение архитектора): определять смерть по
`agent_runs.finished_at/exit_code` + потолку `reaper_max_running_s`, без хранения
pid в `jobs`. ADR должен зафиксировать выбор и обоснование.
- Реестры `STAGE_TRANSITIONS` и `QG_CHECKS`**без изменений** (новых стадий/гейтов
не вводим; reaper и lease-reclaim — фоновые механизмы, не стадии).
- Merge-lease остаётся файловым (`.merge-lease-<repo>.json`); схема файла lease
не меняется (pid и acquired_at уже есть).
## 4. Изменения API
- `GET /queue` — добавить блок `reaper` (read-only наблюдаемость). Прочие endpoints
без изменений. Новых webhook-роутов нет.
## 5. Конфигурация / kill-switches (`src/config.py`)
Именование — по образцу `reconcile_*` / `merge_*`. Кандидаты (точные имена/дефолты
уточняет архитектор):
| Настройка | Назначение | Дефолт (предложение) |
|-----------|-----------|----------------------|
| `reaper_enabled` | глобальный kill-switch job-reaper | `true` |
| `reaper_interval_s` | период сканирования | `60` |
| `reaper_dead_ticks` | сколько подряд тиков pid должен быть мёртв перед reap | `2` |
| `reaper_max_running_s` | потолок «running» (backstop), > max agent_timeout+grace | `3600` |
| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `true` |
| (переиспользуется) `merge_lock_timeout_s` | TTL lease | `300` (как есть) |
| (переиспользуется) `merge_gate_repos` | область применения lease-reclaim | как есть |
Все флаги — пробрасываются из env (`ORCH_*`), `false` → строго прежнее поведение.
## 6. Требования к QG checks
- Новых QG checks НЕ вводить (это фоновые resilience-механизмы, не гейты выхода со
стадии). `check_branch_mergeable` остаётся контрактно неизменным; допускается лишь
переиспользование его как идемпотентного пути финализации merge (FR-3.3).
## 7. Артефакты pipeline, создаваемые/обновляемые в ЭТОМ PR
- Код: см. §1.
- `06-adr/ADR-001-*.md` — архитектурное решение (где живёт reaper; pid-колонка vs
эвристика; механизм идемпотентного merge) — создаёт architect.
- `docs/architecture/README.md` — новый раздел про job-reaper + lease-reclaim
(golden-source, в этом же PR).
- `docs/architecture/internals.md` — детали (если затрагивается схема БД / потоки).
- `CHANGELOG.md` — запись ORCH-065.
- `.env.example` — новые `ORCH_*` флаги (канон секретов/настроек).
- `docs/operations/INFRA.md` — упоминание поведения при self-restart, если
затрагивается (best-effort).
## 8. Инварианты (НЕ нарушать)
- Не ронять/не рестартить прод-контейнер `orchestrator` в рамках задачи.
- Никогда не пушить/форс-пушить `main`; реклейм lease не инициирует git-операций.
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракты `check_*`, БАГ-8 откат,
exit-коды deploy-хука — без изменений.
- never-raise на единицу фоновой работы; идемпотентность; restart-safe; тишина при
отсутствии аномалий (как reconciler).
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.

View File

@@ -0,0 +1,122 @@
# Критерии приёмки — ORCH-065
Work Item ID: ORCH-065
Формат: каждый критерий имеет явное условие PASS/FAIL. Все критерии должны быть
PASS для прохождения review/testing.
## A. Job-reaper (Проблема A)
### AC-1 — реап мёртвого running-job без рестарта
- PASS: при наличии строки `jobs` в статусе `running`, чей процесс/исполнитель
достоверно мёртв (pid не существует ИЛИ `agent_runs.exit_code` записан, а job всё
ещё `running`) и условие устойчивости (FR-1.3) выполнено, фоновый reaper переводит
строку в корректный статус (`done`/`queued`/`failed`) **без перезапуска процесса**.
- FAIL: строка остаётся `running` после `reaper_dead_ticks` тиков / превышения
`reaper_max_running_s`.
### AC-2 — разблокировка очереди при concurrency=1
- PASS: после реапа зомби-строки `count_running_jobs()` снижается, и следующий
queued-job успешно claim'ится воркером.
- FAIL: очередь остаётся заблокированной зомби-строкой.
### AC-3 — анти-ложноположительность (живой долгий агент не реапится)
- PASS: `running`-job с ЖИВЫМ процессом в пределах его `agent_timeout` НЕ помечается
зомби (ни по одному тику, ни в пределах `reaper_max_running_s`, если потолок
больше таймаута).
- FAIL: живой агент помечен `failed`/`queued` reaper'ом.
### AC-4 — корректный исход по результату
- PASS: при `agent_runs.exit_code == 0` reaper доводит до успешного завершения без
дублирования уже выполненного stage-advance (идемпотентно); при неуспехе и
`attempts < max_attempts``queued`; при исчерпании → `failed` + Telegram.
- FAIL: успешный исход помечен `failed`; либо дублируется stage-переход; либо
исчерпанный бюджет молча зацикливается на `queued`.
### AC-5 — restart-safe совместимость
- PASS: одновременная работа стартового `requeue_running_jobs()` и периодического
reaper не приводит к двойной обработке одной строки (атомарный UPDATE с guard
`status='running'`).
- FAIL: одна строка обработана дважды / гонка приводит к рассинхрону статуса.
## B. Stale/dead merge-lease reclaim (Проблема B)
### AC-6 — реклейм lease мёртвого держателя
- PASS: lease `.merge-lease-<repo>.json`, чей `pid` не существует, проактивно
освобождается на старте И периодическим потоком (не дожидаясь TTL и не дожидаясь
чужого `acquire`).
- FAIL: lease мёртвого держателя остаётся до истечения `merge_lock_timeout_s` или
до следующего чужого `acquire`.
### AC-7 — реклейм по TTL сохранён
- PASS: lease старше `merge_lock_timeout_s` освобождается (существующий контракт не
сломан), с `logger.warning`.
- FAIL: просроченный lease не освобождается.
### AC-8 — не трогать живой lease
- PASS: lease с ЖИВЫМ держателем (pid жив) и возрастом `< merge_lock_timeout_s` НЕ
освобождается (защита легитимного merge).
- FAIL: освобождён lease живого держателя → возможен параллельный конфликтный merge.
### AC-9 — условность и never-raise
- PASS: реклейм реален только для `merge_gate_repos`/self-hosting; для прочих репо
— no-op; любая ошибка реклейма логируется и не валит поток (never-raise).
- FAIL: реклейм выполняется для не-self-hosting репо; либо ошибка пробрасывается
наружу/роняет поток.
## C. Идемпотентная финализация merge (Проблема C)
### AC-10 — докатывание незавершённого merge
- PASS: сценарий «rebase+re-test зелёные, merge не состоялся, процесс умер»
восстанавливается автоматически (job → `queued` reaper'ом / reconciler доигрывает),
и merge доводится без повторного ненужного прогона дорогих шагов.
- FAIL: задача остаётся в полу-выполненном состоянии, требует ручного merge.
### AC-11 — идемпотентность при уже слитом PR
- PASS: повторный вызов финализации при уже слитом PR — no-op (определяется по
состоянию PR/`main`), без ошибки и без второго merge.
- FAIL: второй merge / ошибка при уже слитом PR.
## D. Инварианты и безопасность self-hosting
### AC-12 — прод-контейнер не трогается
- PASS: ни reaper, ни lease-reclaim не рестартят/не роняют прод-контейнер и не
инициируют git-push в `main`.
- FAIL: любая из новых веток кода рестартит self / пушит main.
### AC-13 — контракты неизменны
- PASS: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*`,
БАГ-8 откат, exit-коды deploy-хука — без изменений; новых QG checks/стадий нет.
- FAIL: затронут любой из перечисленных контрактов.
### AC-14 — kill-switches
- PASS: `reaper_enabled=false` → reaper не работает (строго прежнее поведение);
`lease_reclaim_enabled=false` → проактивный реклейм отключён (остаётся лишь
прежний ленивый TTL-реклейм в `acquire`).
- FAIL: флаг `false` не отключает соответствующий механизм.
## E. Наблюдаемость
### AC-15 — блок reaper в /queue
- PASS: `GET /queue` содержит блок `reaper` (enabled, interval, last_run_ts,
reaped_total, last_reaped, lease_reclaimed_total).
- FAIL: блок отсутствует/не обновляется.
### AC-16 — логи и алерты
- PASS: каждый reap и lease-reclaim → `logger.warning` с идентификаторами;
reap→`failed` и lease-reclaim → Telegram.
- FAIL: реап/реклейм происходят молча.
## F. Документация (gate reviewer)
### AC-17 — golden-source обновлён в этом же PR
- PASS: обновлены `docs/architecture/README.md` (раздел про reaper + lease-reclaim),
`CHANGELOG.md`, `.env.example` (новые `ORCH_*` флаги); заведён
`06-adr/ADR-001-*.md`.
- FAIL: код изменён, документация — нет (reviewer → REQUEST_CHANGES).
## G. Тесты
### AC-18 — регресс-тесты зелёные
- PASS: новые unit/integration тесты (см. 04-test-plan.yaml) проходят; существующий
`pytest tests/ -q` зелёный (нет регресса merge_gate / queue / reconciler).
- FAIL: любой тест из плана красный или сломан существующий тест.

View File

@@ -0,0 +1,196 @@
work_item: ORCH-065
description: >
Тест-план для фикса zombie jobs (job-reaper), залипшего merge-lease
(проактивный реклейм dead/stale lease) и идемпотентной финализации merge.
Все новые фоновые механизмы — never-raise, restart-safe, kill-switch.
Модуль reaper и точные имена функций уточнит архитектор; в module указаны
кандидатные пути (tests/test_job_reaper.py / tests/test_merge_lease_reclaim.py).
tests:
# ---- A. Job-reaper ----
- id: TC-01
type: unit
description: >
Reaper переводит running-job с мёртвым исполнителем в корректный статус
без рестарта процесса (pid не существует / exit_code записан, job всё ещё
running). Покрывает AC-1.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-02
type: unit
description: >
Анти-ложноположительность: running-job с ЖИВЫМ процессом в пределах
agent_timeout НЕ реапится (ни по одному тику, ни в пределах reaper_max_running_s).
Покрывает AC-3.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-03
type: unit
description: >
Устойчивость: job помечается зомби только после reaper_dead_ticks
последовательных тиков смерти pid (>=2), а не на первом тике. Покрывает FR-1.3.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-04
type: unit
description: >
Backstop по потолку: job, висящий running дольше reaper_max_running_s,
реапится даже если liveness определить нельзя. Покрывает FR-1.1/AC-1.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-05
type: unit
description: >
Корректный исход: exit_code==0 -> успешное завершение без дублирования
stage-advance; неуспех при attempts<max -> queued; исчерпан бюджет -> failed
+ Telegram. Покрывает AC-4.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-06
type: unit
description: >
Атомарность reap-UPDATE с guard status='running': параллельная обработка
одной строки (стартовый requeue_running_jobs + reaper) не приводит к двойному
reap. Покрывает AC-5.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-07
type: unit
description: >
Kill-switch reaper_enabled=false -> reaper не трогает ни одной строки
(строго прежнее поведение). Покрывает AC-14.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-08
type: unit
description: >
never-raise: ошибка БД/ОС внутри одного тика reaper не пробрасывается и не
останавливает поток (изоляция per-job, образец reconciler). Покрывает AC-9.
module: tests/test_job_reaper.py
expected: PASS
- id: TC-09
type: integration
description: >
Очередь разблокируется: после reap зомби-строки при max_concurrency=1
следующий queued-job успешно claim'ится (claim_next_job + count_running_jobs).
Покрывает AC-2.
module: tests/test_queue.py
expected: PASS
# ---- B. Stale/dead merge-lease reclaim ----
- id: TC-10
type: unit
description: >
Реклейм lease с мёртвым pid (os.kill(pid,0) -> ProcessLookupError)
проактивно, не дожидаясь TTL и чужого acquire. Покрывает AC-6.
module: tests/test_merge_lease_reclaim.py
expected: PASS
- id: TC-11
type: unit
description: >
Реклейм по TTL (age >= merge_lock_timeout_s) сохранён, с logger.warning.
Покрывает AC-7. (расширяет существующий stale-lease сценарий merge_gate.)
module: tests/test_merge_lease_reclaim.py
expected: PASS
- id: TC-12
type: unit
description: >
Живой lease (pid жив, age < TTL) НЕ освобождается — защита легитимного merge.
Покрывает AC-8.
module: tests/test_merge_lease_reclaim.py
expected: PASS
- id: TC-13
type: unit
description: >
Условность: реклейм реален только для merge_gate_repos/self-hosting; для
прочих репо no-op. Покрывает AC-9.
module: tests/test_merge_lease_reclaim.py
expected: PASS
- id: TC-14
type: unit
description: >
never-raise: ошибка чтения/удаления lease-файла не валит реклейм-поток.
Покрывает AC-9.
module: tests/test_merge_lease_reclaim.py
expected: PASS
- id: TC-15
type: unit
description: >
Kill-switch lease_reclaim_enabled=false -> проактивный реклейм отключён,
остаётся лишь прежний ленивый TTL-реклейм в acquire_merge_lease.
Покрывает AC-14.
module: tests/test_merge_lease_reclaim.py
expected: PASS
# ---- C. Идемпотентная финализация merge ----
- id: TC-16
type: unit
description: >
Идемпотентность финализации: повторный вызов при уже слитом PR / уже
актуальном main (branch_is_behind_main == False) — no-op, без ошибки и без
второго merge. Покрывает AC-11.
module: tests/test_merge_gate.py
expected: PASS
- id: TC-17
type: integration
description: >
Восстановление: сценарий "rebase+re-test зелёные, merge не состоялся,
процесс умер" -> job доводится до queued reaper'ом и merge докатывается
штатным путём без повторного дорогого re-test, когда безопасно. Покрывает AC-10.
module: tests/test_merge_gate_race.py
expected: PASS
# ---- D/E. Инварианты и наблюдаемость ----
- id: TC-18
type: integration
description: >
GET /queue содержит блок reaper (enabled, interval, last_run_ts,
reaped_total, last_reaped, lease_reclaimed_total). Покрывает AC-15.
module: tests/test_queue.py
expected: PASS
- id: TC-19
type: unit
description: >
Контракты неизменны: STAGE_TRANSITIONS и реестр QG_CHECKS не получили новых
стадий/чеков; check_branch_mergeable сигнатурно не изменён. Покрывает AC-13.
module: tests/test_config.py
expected: PASS
- id: TC-20
type: unit
description: >
Новые настройки reaper_*/lease_reclaim_* присутствуют в config с дефолтами и
читаются из ORCH_* env. Покрывает §5 ТЗ / AC-14.
module: tests/test_config.py
expected: PASS
- id: TC-21
type: unit
description: >
Стартовый реклейм stale/dead lease вызывается в lifespan рядом с
requeue_running_jobs (smoke на регистрацию старт/стоп reaper-потока).
Покрывает FR-2.1 / AC-6.
module: tests/test_job_reaper.py
expected: PASS
regression:
- command: pytest tests/ -q
expected: PASS
note: >
Полный прогон не должен ломать существующие тесты merge_gate / queue /
reconciler / deploy.

View File

@@ -0,0 +1,275 @@
# ADR-001 (ORCH-065): Job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge
## Статус
Accepted — 2026-06-07
Связь со сквозным ADR: [docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md](../../../architecture/adr/adr-0011-job-reaper-lease-reclaim.md).
## Контекст
Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
`max_concurrency=1` для self-hosting). BRD/ТЗ фиксируют три связанных класса
отказов «процесс/поток умер, а состояние осталось захваченным навсегда»:
- **A — zombie jobs.** Статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО
в `launcher._monitor_agent``_finalize_job` внутри того же процесса. Смерть
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
self-restart во время deploy) оставляет строку `jobs` навсегда `running`.
Единственная защита — `requeue_running_jobs()`, срабатывает ТОЛЬКО на старте
процесса. Зомби без рестарта не реанимируется никогда. При `max_concurrency=1`
одна зомби-строка блокирует claim всех job (`count_running_jobs() >=
max_concurrency`) → встаёт конвейер ВСЕХ проектов. Доказано: jobs 236/239/242/254
(07.06).
- **B — залипший merge-lease.** Файловый lease `.merge-lease-<repo>.json`
(ORCH-043) реклеймится **лениво и только по возрасту** (`age >=
merge_lock_timeout_s`) и **только** в момент `acquire_merge_lease` другой
задачей. Liveness держателя по pid не проверяется, хотя pid в lease пишется.
Смерть с зажатым lease блокирует чужие merge до истечения TTL.
- **C — неидемпотентная финализация merge.** Если rebase+re-test зелёные, но
процесс умер до фактического merge PR — повторного докатывания нет; дорогая
работа (rebase+re-test) сделана, а задача висит.
Факты кода, на которых строится решение:
- `_spawn` (launcher.py:401) создаёт `subprocess.Popen(["bash","-c",cmd])`;
`proc.pid` — pid агентского процесса, дочернего к процессу оркестратора в ОДНОМ
pid-namespace контейнера. Сейчас `jobs.pid` НЕ хранится.
- `_monitor_agent` (launcher.py:541) порядок: `proc.wait()` → запись
`agent_runs.finished_at/exit_code` → git commit/push (+PR) → БАГ-8 deployer
rollback → usage-комменты → `_try_advance_stage` (exit0, gate-driven advance)
`_finalize_job` (драйв статуса job по контракту attempts/transient).
- `claim_next_job` (db.py:454) — атомарный claim через `UPDATE ... WHERE id=? AND
status='queued'` + `rowcount` (образец атомарности).
- `reconciler.py` — образец фонового daemon-потока (never-raise, `_stop`-Event,
старт/стоп в `main.lifespan`, снимок в `/queue`, kill-switch).
- `merge_gate.py`: lease пишет `pid=os.getpid()` (pid процесса оркестратора, НЕ
агента), `acquired_at`; `release_merge_lease` уже holder-aware (по `branch`) и
идемпотентен; `acquire_merge_lease` идемпотентен для «held by self» (по branch).
- `is_self_hosting_repo` / `merge_gate_repos` — образец условности (ORCH-35/43).
## Решение
### Р-1. Job-reaper — отдельный daemon-поток `src/job_reaper.py`
Reaper — **новый модуль и отдельный daemon-поток** (НЕ расширение reconciler).
Обоснование: reconciler работает на уровне **stage-transition** (источник истины —
гейт/Plane); reaper работает на уровне **jobs/agent_runs** (источник истины —
liveness процесса). Это разные never-raise-домены и разные kill-switch'и; слияние
в один тик смешало бы ответственности. Reaper копирует проверенный каркас
`Reconciler`: `threading.Thread(daemon=True)` + `threading.Event`, старт/стоп в
`main.lifespan`, снимок в `/queue`, per-job изоляция исключений.
**Liveness — трёхуровневая (defense in depth):**
1. **Tier-1 (liveness, основной): мёртвый pid.** Добавляем колонку `jobs.pid`
(см. Р-4). В `_spawn` рядом с `run_id`/`started_at` пишем `proc.pid`. Reaper:
`pid_alive(pid)` = `os.kill(pid, 0)` с обработкой `ProcessLookupError` (мёртв)
/ `PermissionError` (жив, чужой) — единственный сигнал, ловящий «monitor умер
ДО записи `finished_at`».
2. **Tier-2 (completion race): exit_code записан, job ещё `running`.** Окно
**неоднозначно**: это И «monitor умер между записью exit_code и
`_finalize_job`», И «живой monitor ещё финализирует» — `_monitor_agent`
пишет `exit_code` ПЕРВЫМ, затем git commit/push (+PR), БАГ-8-проверку и
сетевые usage-комментарии в Plane (секунды-десятки секунд), и лишь потом
`_try_advance_stage` → `_finalize_job`. pid агента к этому моменту уже мёртв в
ОБОИХ случаях, поэтому по pid их не различить. **Анти-ложноположительность
Tier-2 (FR-1.3, AC-3): finalization-grace.** Job реапится по Tier-2 только
когда `exit_code` записан не меньше `reaper_finalize_grace_s` назад (потолок
заведомо > максимального окна финализации). В пределах grace строка не
трогается (живой финализирующий monitor НИКОГДА не реапится; нет дубль-advance
/ дубль-enqueue). После grace monitor заведомо мёртв → исход **известен**.
3. **Tier-3 (backstop по потолку):** job висит `running` дольше
`reaper_max_running_s` (заведомо > max `agent_timeout`+grace). Реап даже когда
liveness определить нельзя (pid переиспользован/неизвестен).
**Анти-ложноположительность (FR-1.3, AC-3):** по Tier-1 job реапится только после
`reaper_dead_ticks` (≥2) ПОДРЯД тиков мёртвого pid — in-memory streak-счётчик по
`job_id` (best-effort, сбрасывается на рестарте — но рестарт покрыт стартовым
`requeue_running_jobs`). Tier-3 — одношаговый (порог времени, streak не нужен).
Живой агент в пределах своего `agent_timeout` НЕ реапится никогда (pid жив + не
превышен потолок).
**Действие при подтверждённой смерти (FR-1.2, AC-4) — переиспользование
существующих контрактов, без дублирования:**
- **Атомарный reap-claim.** Перед любым действием с побочными эффектами reaper
атомарно «застолбляет» строку тем же приёмом, что `claim_next_job`: терминальный
flip несёт guard `WHERE id=? AND status='running'` и проверяет `rowcount`. При
гонке (поздно доехавший monitor, стартовый `requeue_running_jobs`) проигравший
видит `rowcount==0` и НЕ обрабатывает строку повторно (AC-5).
- **Исход известен (Tier-2, exit_code в `agent_runs`, grace прошёл):**
- `exit==0`: **claim-BEFORE-act, gate-driven idempotent advance.** Порядок
критичен (см. «Атомарный reap-claim» выше): атомарный claim ОБЯЗАН
предшествовать любому `advance_stage`/`enqueue_job`. Поскольку claim
переводит строку ИЗ `running`, прогнать advance ДО claim, чтобы узнать цвет
гейта, нельзя — поэтому канонический QG оценивается **read-only, без
побочных эффектов** (тот же `_run_qg`, что у reconciler) ПЕРЕД claim:
- стадия уже продвинута мимо этого агента → атомарный `done` без advance
(идемпотентная уборка);
- гейт зелёный → атомарный claim `done` ПЕРВЫМ, и только победитель claim
выполняет `_try_advance_stage` (advance + `enqueue_job` следующей стадии)
РОВНО один раз; проигравший claim (поздний monitor / стартовый
`requeue_running_jobs`) НЕ делает побочных эффектов (нет дубль-advance /
дубль-enqueue);
- гейт красный (monitor умер ДО git-push, артефакта нет) → НЕ выдумываем
`done`, уходим в ветку «исход неуспешен» ниже.
**Источник истины — гейт, не «exit0».**
- `exit!=0`: ровно существующий контракт `_finalize_job` (классификация
transient/permanent, `attempts<max` → `queued`, иначе `failed`+Telegram).
- **Исход неизвестен (Tier-1 мёртвый pid без exit_code, или Tier-3 backstop):**
не выдумываем `exit0`. Если `attempts < max_attempts` → `queued` (как
`requeue_running_jobs`); если бюджет исчерпан → `failed` + Telegram-алерт.
**Restart-safe (FR-1.5, AC-5):** reaper стартует в `lifespan` ПОСЛЕ
`requeue_running_jobs()`, поэтому при рестарте сначала отрабатывает стартовый
requeue, а периодический reaper лишь добивает то, что возникнет позже; атомарный
guard `status='running'` исключает двойную обработку.
### Р-2. Проактивный реклейм stale/dead merge-lease — функции в `merge_gate.py`
Логика lease живёт в одном месте (`merge_gate.py`). Добавляем:
- `pid_alive(pid) -> bool` (never-raise; ошибка/`PermissionError` → считаем
«жив», т.е. консервативно НЕ реклеймим — безопаснее не трогать).
- `reclaim_stale_lease(repo) -> bool` — для репо из области (см. ниже): прочитать
lease; освободить (`release_merge_lease(repo, branch=holder_branch)` —
holder-aware), если держатель **мёртв** (`pid` из lease не жив) ИЛИ **просрочен**
(`age >= merge_lock_timeout_s`). Живой держатель в пределах TTL — НЕ трогать
(AC-8, защита легитимного merge). Каждый реклейм → `logger.warning` +
Telegram.
**Точки вызова (FR-2.1):**
- на старте — в `lifespan` рядом с `requeue_running_jobs()`;
- периодически — из тика reaper (один общий фоновый поток на оба механизма A и B).
**Условность (FR-2.4, AC-9):** реально только для `merge_gate_repos`/self-hosting
(тот же предикат, что merge-gate); прочие репо — no-op. Kill-switch
`lease_reclaim_enabled` (=false → остаётся лишь прежний ленивый TTL-реклейм в
`acquire_merge_lease`). Контракт **never-raise**: ошибка реклейма логируется и не
валит поток.
**pid-семантика lease:** lease пишет pid процесса ОРКЕСТРАТОРА (`os.getpid()`).
После рестарта контейнера старый pid мёртв → детектируется. Риск pid-reuse
(контейнер мог переиспользовать номер pid) закрыт тем, что реклейм срабатывает по
**ИЛИ** (pid мёртв **ИЛИ** TTL истёк): даже при ложном «жив» TTL добьёт lease
(контракт ORCH-043 сохранён). См. 10-tech-risks.
### Р-3. Идемпотентная финализация merge (Проблема C) — re-drive + guard, без новой merge-логики
Per FR-3.3 — НЕ создаём дублирующую merge-логику. Восстановление обеспечивается
**штатными путями**:
- reaper доводит зомби-job до `queued` → стадия `deploy-staging`/`deploy`
переисполняется и снова проходит `check_branch_mergeable` (merge-gate), ЛИБО
reconciler доигрывает переход по зелёному гейту;
- дорогие шаги не повторяются «вхолостую»: `branch_is_behind_main == False` → этап
rebase+re-test пропускается (ветка уже догнана);
- lease при повторе берётся заново — `acquire_merge_lease` уже идемпотентен для
«held by self» по branch (FR-3.4).
**Идемпотентность у самого merge (FR-3.2, AC-11):** добавляем детерминированный
never-raise guard `pr_already_merged(repo, branch) -> bool` (переиспользует
существующий Gitea-клиент; запрос состояния PR). Путь слияния (deployer/merge)
консультируется с этим guard ПЕРЕД повторным merge: PR уже слит → no-op (без
второго merge и без ошибки). Это единственная новая «merge-related» функция — она
не сливает, а лишь читает состояние, поэтому не нарушает «no duplicate merge
logic».
### Р-4. Изменение схемы БД — `jobs.pid INTEGER` (lightweight migration)
Колонка добавляется идемпотентно через существующий `_ensure_column(conn, "jobs",
"pid", "INTEGER")` в `init_db` (паттерн уже применяется к `jobs.transient_attempts`
/ `jobs.available_at` — безопасно на live prod DB). `pid` проставляется в `_spawn`
рядом с `run_id`/`started_at`. **Альтернатива без миграции отвергнута** (см.
Альтернативы): только по `agent_runs.finished_at/exit_code` нельзя поймать
зомби, у которого monitor умер ДО записи exit_code — а это и есть основной класс
инцидента. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема `agent_runs`, файл-схема lease —
без изменений.
### Р-5. Конфигурация (`src/config.py`, env `ORCH_*`)
| Настройка | Назначение | Дефолт |
|-----------|-----------|--------|
| `reaper_enabled` | глобальный kill-switch job-reaper | `True` |
| `reaper_interval_s` | период сканирования | `60` |
| `reaper_dead_ticks` | подряд тиков мёртвого pid перед реапом (Tier-1) | `2` |
| `reaper_max_running_s` | потолок `running` (Tier-3 backstop), > max agent_timeout+grace | `3600` |
| `reaper_finalize_grace_s` | Tier-2 grace: сколько `exit_code` должен быть записан до реапа (> max окна финализации) | `300` |
| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `True` |
| (reuse) `merge_lock_timeout_s` | TTL lease | `300` |
| (reuse) `merge_gate_repos` | область применения lease-reclaim | как есть |
`false` → строго прежнее поведение (AC-14).
### Р-6. Наблюдаемость (`GET /queue`)
Блок `reaper` (образец `reconcile`/`post_deploy`): `enabled`, `interval`,
`last_run_ts`, `reaped_total`, `last_reaped` (`{job_id, agent, outcome}`),
`lease_reclaimed_total`. Каждый reap и lease-reclaim → `logger.warning` с
идентификаторами (`job_id`, `run_id`, `pid`, `repo`, `branch`). reap→`failed` и
lease-reclaim → Telegram (AC-16).
### Р-7. Lifespan (`src/main.py`)
Старт (после существующего `requeue_running_jobs()`):
```
init_db() # + _ensure_column(jobs, pid)
... orphan-recovery (M-1) ...
requeue_running_jobs()
+ startup lease-reclaim # reclaim_stale_lease для merge_gate_repos
worker.start()
reconciler.start()
+ reaper.start() # НОВЫЙ daemon-поток (A + периодический B)
```
Стоп (reverse): `reaper.stop()` → `reconciler.stop()` → `worker.stop()`.
## Альтернативы
- **Reaper как часть reconciler** — отвергнуто: смешивает stage-уровень и
jobs-уровень, два разных kill-switch'а в одном тике, хуже изоляция отказов.
- **Без `jobs.pid`, только эвристика `agent_runs` + потолок** — отвергнуто как
основной механизм: не ловит зомби, чей monitor умер ДО записи `exit_code`
(главный класс инцидента). Эвристика оставлена как Tier-2/Tier-3 поверх pid.
- **БД-lock вместо файлового lease / внешний брокер очередей** — вне объёма
(BRD §4), несоразмерно для single-node SQLite.
- **Реаниматор фабрикует `exit0` и форсит `done`** — отвергнуто: ложный `done`
без реальной работы (если git-push не случился). Выбран gate-driven advance:
источник истины — канонический QG, не предположение об успехе.
- **Новый статус `reaping` в enum `jobs.status`** — отвергнуто: меняет контракт
статусов; атомарного guard `WHERE status='running'` достаточно.
## Последствия
**Плюсы:**
- Зомби-job самовосстанавливается БЕЗ рестарта процесса → очередь не встаёт
(групповой риск снят для всех проектов общего инстанса).
- Залипший lease освобождается проактивно (старт + период), не дожидаясь TTL и
чужого acquire.
- Незавершённый merge докатывается штатным путём, идемпотентно; ручные шаги
оператора устранены → снят технический блокер ORCH-54.
- Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
exit-коды хука); один новый столбец через проверенный idempotent-паттерн.
**Минусы / ограничения:**
- pid-liveness валиден в предположении ОДНОГО pid-namespace (агент — дочерний
процесс оркестратора в том же контейнере). Multi-container/namespaced pid →
pid-liveness ненадёжен; закрыто backstop'ом по времени и TTL. См. 10-tech-risks.
- streak-счётчик in-memory best-effort: после рестарта он сбрасывается, но
стартовый `requeue_running_jobs` покрывает рестарт-сценарий.
- Tier-3 backstop консервативен (потолок > max timeout); очень долгий легитимный
агент (близко к потолку) теоретически может быть реапнут — потолок выбран с
большим запасом, чтобы этого не случалось (AC-3).
## Инварианты (НЕ нарушать)
- Reaper/lease-reclaim НЕ рестартят/не роняют прод-контейнер `orchestrator` и НЕ
инициируют git-push в `main` (AC-12). Реклейм lease — только удаление
файла-lease, без git-операций.
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*` (в т.ч.
`check_branch_mergeable`), БАГ-8 откат, exit-коды deploy-хука — без изменений;
новых QG checks/стадий нет (AC-13).
- never-raise на единицу фоновой работы; идемпотентность (атомарный guard +
gate-driven advance); restart-safe; тишина при отсутствии аномалий.
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.
## Связи
- Базируется на: ORCH-1 (очередь, adr-0002), ORCH-043 (merge-gate, adr-0006),
ORCH-053 (reconciler-паттерн, adr-0007), ORCH-36 (self-deploy, adr-0007).
- Сквозной ADR: adr-0011.
- Разблокирует: ORCH-54 (полностью автономный self-deploy).

View File

@@ -0,0 +1,42 @@
# 07 — Требования к инфраструктуре (ORCH-065)
## Топология
**Без изменений.** Новых контейнеров, портов, сетевых сервисов, внешних
зависимостей нет. Job-reaper — ещё один фоновый daemon-поток ВНУТРИ существующего
процесса оркестратора (как `queue_worker` и `reconciler`), стартует/останавливается
в `main.lifespan`. Деплой/рестарт прод-контейнера в рамках задачи НЕ требуется и
ЗАПРЕЩЁН (self-hosting safety) — выкатка через штатный `deploy-staging → deploy`.
## Допущение pid-namespace (важно для liveness-детекции)
- Агент запускается как `subprocess.Popen(["bash","-c",cmd])` — **дочерний
процесс оркестратора в ТОМ ЖЕ pid-namespace** (один контейнер). Значит
`os.kill(jobs.pid, 0)` корректно отражает liveness агента, пока жив сам
оркестратор. Это инвариант текущей упаковки (один контейнер на инстанс).
- Lease пишет `pid = os.getpid()` — pid ПРОЦЕССА ОРКЕСТРАТОРА. После рестарта
контейнера старый pid мёртв → детектируется. Риск переиспользования номера pid
новым процессом закрыт условием «pid мёртв **ИЛИ** TTL истёк»: TTL добивает
lease в любом случае (контракт ORCH-043 сохранён).
- **Если в будущем агенты переедут в отдельные контейнеры/namespace** — Tier-1
pid-liveness станет ненадёжной; тогда полагаемся на Tier-2 (exit_code) и Tier-3
(потолок `reaper_max_running_s`). Зафиксировано в 10-tech-risks.
## Поведение при self-restart (ORCH-36 executable self-deploy)
Self-restart прод-контейнера во время `deploy` — ровно тот сценарий, что плодит
зомби: monitor-поток умирает вместе со старым контейнером. После рестарта:
1. стартовый `requeue_running_jobs()` + стартовый `reclaim_stale_lease` чистят
состояние, оставшееся от убитого процесса;
2. периодический reaper добивает то, что возникнет позже без рестарта.
Reaper/lease-reclaim сами НИКОГДА не рестартят и не роняют прод-контейнер и не
делают git-push в `main` (AC-12).
## Эксплуатационные ручки (env, хост `.env`/`.env.staging`)
`ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`, `ORCH_REAPER_DEAD_TICKS`,
`ORCH_REAPER_MAX_RUNNING_S`, `ORCH_LEASE_RECLAIM_ENABLED`; переиспользуются
`ORCH_MERGE_LOCK_TIMEOUT_S`, `ORCH_MERGE_GATE_REPOS`. Все флаги документируются в
`.env.example` (developer-стадия). Полное отключение (`false`) → строго прежнее
поведение.
## Документация эксплуатации
`docs/operations/INFRA.md` — добавить (best-effort, developer/PR) короткое
упоминание поведения reaper/lease-reclaim при self-restart. Топологическая карта
INFRA.md не меняется.

View File

@@ -0,0 +1,29 @@
# 08 — Требования к данным (ORCH-065)
## Изменение схемы: `jobs.pid`
| Поле | Значение |
|------|----------|
| Таблица | `jobs` |
| Колонка | `pid` |
| Тип | `INTEGER` (nullable, без DEFAULT) |
| Назначение | pid агентского процесса (`subprocess.Popen.pid` из `launcher._spawn`) для liveness-детекции зомби job-reaper'ом (Tier-1) |
| Механизм миграции | `_ensure_column(conn, "jobs", "pid", "INTEGER")` в `db.init_db` — идемпотентно, no-op если колонка уже есть |
| Безопасность на live prod DB | ДА. Тот же паттерn уже применён к `jobs.transient_attempts`, `jobs.available_at`, `events.delivery_id`, `agent_runs.*`. `ALTER TABLE ADD COLUMN` в SQLite — мгновенная метаданная-операция, не блокирует и не переписывает строки |
| Заполнение | в `_spawn` рядом с существующим `UPDATE jobs SET run_id=?, started_at=datetime('now') WHERE id=?` добавить `pid=?` (`proc.pid`). Старые строки остаются `pid IS NULL` → для них Tier-1 неприменим, работают Tier-2/Tier-3 |
## Что НЕ меняется
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS` — без изменений (это контракты).
- Схема `agent_runs` — без изменений (`finished_at`/`exit_code` уже есть — основа Tier-2).
- Файл-схема merge-lease `.merge-lease-<repo>.json` — без изменений (`pid`,
`acquired_at`, `branch`, `work_item_id`, `task_id` уже пишутся
`acquire_merge_lease`).
- `jobs.status` enum (`queued|running|done|failed`) — без изменений; новый статус
`reaping` НЕ вводится (атомарного guard `WHERE status='running'` достаточно).
## Совместимость / откат
- Откат миграции не требуется: лишняя nullable-колонка безвредна при
`reaper_enabled=false`.
- `pid IS NULL` (строки до миграции, или если запись pid не успела) → reaper не
делает Tier-1, опирается на Tier-2 (exit_code) и Tier-3 (потолок). Поведение
деградирует gracefully, ложноположительных реапов не возникает.

View File

@@ -0,0 +1,22 @@
# 10 — Технические риски (ORCH-065)
| # | Риск | Вероятн. | Влияние | Митигация |
|---|------|----------|---------|-----------|
| R-1 | **Ложноположительный реап живого долгого агента** (AC-3). Reaper помечает зомби работающий агент → потеря работы, дубль-запуск. | Сред. | Высокое | Tier-1 требует `reaper_dead_ticks`(≥2) подряд тиков мёртвого pid; живой pid = `os.kill(pid,0)` без `ProcessLookupError`. Tier-3 потолок `reaper_max_running_s` выбирается заведомо > max `agent_timeout`+grace. Юнит-тест TC-02/TC-03. |
| R-2 | **Ложный `done` без выполненной работы.** Reaper при exit0-зомби помечает job done, хотя git-push/advance не случились (monitor умер до них). | Сред. | Высокое | Реап exit0 НЕ форсит done напрямую — идёт через **gate-driven** `_try_advance_stage`: канонический QG проверяет наличие артефакта/PR; нет артефакта → красный гейт → НЕ advance → ветка «исход неуспешен» (requeue). Источник истины — гейт, не «exit0». |
| R-3 | **pid-reuse / namespace.** Номер pid переиспользован новым процессом → ложное «жив» (lease не реклеймится; зомби-job не реапится по Tier-1). | Низк. | Сред. | Lease: условие «pid мёртв **ИЛИ** TTL истёк» — TTL добивает в любом случае. Job-reaper: Tier-3 backstop по времени ловит то, что Tier-1 пропустил. Допущение «один pid-namespace» зафиксировано в 07-infra. |
| R-4 | **Гонка reaper vs поздно доехавший monitor / стартовый `requeue_running_jobs`** → двойная обработка строки. | Сред. | Сред. | Атомарный reap-claim `UPDATE ... WHERE id=? AND status='running'` + проверка `rowcount` (образец `claim_next_job`). Reaper стартует ПОСЛЕ `requeue_running_jobs` в lifespan. Юнит-тест TC-06. |
| R-5 | **Реклейм живого lease** → параллельный конфликтный merge, риск красного `main` self-hosting. | Низк. | Высокое | `reclaim_stale_lease` освобождает ТОЛЬКО при «держатель мёртв ИЛИ TTL истёк»; живой держатель в пределах TTL не трогается. holder-aware `release_merge_lease(repo, branch)`. Юнит-тест TC-12. |
| R-6 | **Реклейм инициирует git-операцию / трогает прод-контейнер** (нарушение self-hosting safety, AC-12). | Низк. | Высокое | Реклейм = только удаление файла-lease (`os.remove`), без git. Reaper не вызывает деплой-хук/рестарт. Явный инвариант в ADR + тест/ревью. |
| R-7 | **Идемпотентность merge не достигнута**: повторный проход стадии делает второй merge уже слитого PR. | Сред. | Сред. | never-raise guard `pr_already_merged(repo,branch)` (читает состояние PR) консультируется перед merge → уже слит = no-op. `branch_is_behind_main==False` пропускает rebase+re-test. Юнит-тест TC-16, интеграция TC-17. |
| R-8 | **streak-счётчик in-memory теряется при рестарте** → задержка реапа или сброс прогресса. | Низк. | Низкое | Рестарт-сценарий покрыт стартовым `requeue_running_jobs` (мгновенно чистит running). Периодический reaper нужен лишь для зомби БЕЗ рестарта; сброс счётчика лишь переоткладывает реап на `reaper_dead_ticks` тиков. |
| R-9 | **never-raise нарушен** — необработанное исключение валит daemon-поток reaper → защита тихо отключается. | Низк. | Сред. | Per-job изоляция `try/except` (образец `reconciler.reconcile_gate_once`) + внешний `try/except` в `_run`. Юнит-тест TC-08/TC-14. |
| R-10 | **Регресс существующих тестов** merge_gate/queue/reconciler/deploy. | Низк. | Сред. | Контракты неизменны (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/exit-коды хука); только новая колонка + новый поток + флаги (дефолт сохраняет поведение). Полный прогон `pytest tests/ -q` (regression в 04-test-plan). |
## Открытые вопросы / follow-up
- **Полная автоматизация merge-финализации.** Если деплой-merge (deployer/ORCH-36
detached host-process) окажется не полностью идемпотентным к повторному проходу,
может понадобиться доп. работа поверх `pr_already_merged`. Здесь закрываем
технический блокер; полный авто-approve деплоя — ORCH-54.
- Допущение «агенты — дочерние процессы в одном pid-namespace» (R-3) должно быть
пересмотрено, если упаковка агентов изменится (отдельные контейнеры).

View File

@@ -0,0 +1,70 @@
---
type: review
work_item_id: ORCH-065
verdict: APPROVED
version: 3
---
# Review ORCH-065
## Summary
Задача закрывает три связанных класса отказов «процесс/поток умер, а ресурс остался
захваченным навсегда»: zombie jobs (A), залипший merge-lease (B), неидемпотентная
финализация merge (C). Реализация качественная: новый daemon-поток `src/job_reaper.py`
по образцу `reconciler` (never-raise, kill-switch, снимок в `/queue`), трёхуровневая
liveness, атомарный `reap_running_job(... WHERE status='running')`, проактивный реклейм
lease (`pid_alive` + `reclaim_stale_lease`), идемпотентный guard `pr_already_merged`,
колонка `jobs.pid` через идемпотентный `_ensure_column`.
**Все блокеры предыдущих ревью устранены:**
- v1 P0 (guard `pr_already_merged` не подключён к merge-пути) — устранён `aa46e5d`:
промпт `.openclaw/agents/deployer.md` консультирует `pr_already_merged` ПЕРЕД любым
(повторным) merge (AC-11 wiring на месте, подтверждено строками 94105/152).
- v2 P1 (Tier-2 реапит живой финализирующий monitor; side-effects ДО атомарного claim,
нарушение ADR-001 Р-1) — устранён `3e2eb27` двумя мерами:
1. **Tier-2 finalization grace** — новая колонка `finished_age_s` в `get_running_jobs`
(`src/db.py:609`) + настройка `reaper_finalize_grace_s` (дефолт 300с); Tier-2
реапит только при `finished_age >= grace`, иначе строка не трогается
(`src/job_reaper.py:197-209`). Живой финализирующий monitor больше не реапится
(FR-1.3/AC-3).
2. **claim-before-act**`_reap_exit0` (`src/job_reaper.py:242-286`) сначала оценивает
канонический QG read-only (`_gate_is_green``_run_qg`, без побочных эффектов),
затем атомарно claim `done` ПЕРВЫМ, и только победитель claim выполняет
`_gate_driven_advance`. Проигравший гонку (поздний monitor / стартовый requeue) не
делает НИКАКИХ побочных эффектов → нет дубль-advance/дубль-enqueue (FR-1.2/AC-4).
- v2 P3 (битая ссылка на adr-0011 в CHANGELOG) — исправлена в `3e2eb27`
(`adr-0011-job-reaper-lease-reclaim.md`).
Инварианты сохранены (AC-13): ORCH-065-коммиты (`1a2e881`/`aa46e5d`/`3e2eb27`) НЕ касаются
`src/stages.py` и `src/qg/checks.py``STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/
exit-коды хука не тронуты; реклейм lease — только удаление файла, без git-операций
(AC-12). Документация (README, internals, ADR-001, глобальный adr-0011, CHANGELOG,
.env.example) обновлена в этом же PR (AC-17). Новые тесты покрывают grace-окно,
lost-claim-no-side-effects, already-advanced-идемпотентность. `pytest tests/ -q`
**747 passed**.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice to have
- нет
## Документация
Обновлена корректно и в этом же PR (AC-17 PASS): `docs/architecture/README.md`
(раздел про job-reaper + lease-reclaim, таблицы БД и `/queue`),
`docs/architecture/internals.md`, `docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md`
(+ запись в `adr/README.md`),
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`, `CHANGELOG.md`
(ссылка на adr-0011 исправлена), `.env.example` (флаги `ORCH_REAPER_*` /
`ORCH_REAPER_FINALIZE_GRACE_S` / `ORCH_LEASE_RECLAIM_ENABLED`). ADR-001 Р-1 и реализация
exit0-пути теперь согласованы (claim-before-act).

View File

@@ -0,0 +1,92 @@
---
type: test-report
work_item_id: ORCH-065
result: PASS
---
# Test Report — ORCH-065
Тема: job-reaper + проактивный реклейм stale/dead merge-lease + идемпотентная
финализация merge. Прогон полного регресса в ветке
`feature/ORCH-065-bug-zombie-jobs-merge-lease-ru`. Review-вердикт — APPROVED (v3).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: feature/ORCH-065-bug-zombie-jobs-merge-lease-ru (worktree)
- Прод (8500): health `200 {"status":"ok"}`НЕ перезапускался (self-hosting инвариант соблюдён)
- Дата: 2026-06-07
## Smoke API (прод 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | 200, активные задачи отдаются (ORCH-065 в `testing`, ET-013 в `development`) |
| `GET /queue` | 200, counts/resilience/reconcile/post_deploy присутствуют |
Примечание: блок `reaper` в `/queue` прода (8500) ОТСУТСТВУЕТ — ожидаемо, т.к. прод
исполняет ещё не задеплоенный (до-ORCH-065) код. Контракт блока `reaper` проверен
тестом TC-18 (`tests/test_queue.py::test_tc18_queue_endpoint_has_reaper_block`)
против кода ветки — PASS. Curl недоступен в окружении, smoke выполнен через
`urllib.request` (read-only, без побочных эффектов на прод).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Тип | Модуль | Покрывает | Результат |
|-------|-----|--------|-----------|-----------|
| TC-01 | unit | test_job_reaper.py | AC-1 (реап мёртвого job без рестарта) | PASS |
| TC-02 | unit | test_job_reaper.py | AC-3 (живой агент не реапится) | PASS |
| TC-03 | unit | test_job_reaper.py | FR-1.3 (устойчивость reaper_dead_ticks) | PASS |
| TC-04 | unit | test_job_reaper.py | FR-1.1/AC-1 (backstop reaper_max_running_s) | PASS |
| TC-05 | unit | test_job_reaper.py | AC-4 (исход по результату: done/queued/failed) | PASS |
| TC-06 | unit | test_job_reaper.py | AC-5 (атомарность reap-UPDATE guard) | PASS |
| TC-07 | unit | test_job_reaper.py | AC-14 (kill-switch reaper_enabled=false) | PASS |
| TC-08 | unit | test_job_reaper.py | AC-9 (never-raise per-job) | PASS |
| TC-09 | integration | test_queue.py | AC-2 (разблокировка очереди concurrency=1) | PASS |
| TC-10 | unit | test_merge_lease_reclaim.py | AC-6 (реклейм lease мёртвого pid) | PASS |
| TC-11 | unit | test_merge_lease_reclaim.py | AC-7 (реклейм по TTL сохранён) | PASS |
| TC-12 | unit | test_merge_lease_reclaim.py | AC-8 (живой lease не трогается) | PASS |
| TC-13 | unit | test_merge_lease_reclaim.py | AC-9 (условность self-hosting/no-op) | PASS |
| TC-14 | unit | test_merge_lease_reclaim.py | AC-9 (never-raise при ошибке lease-файла) | PASS |
| TC-15 | unit | test_merge_lease_reclaim.py | AC-14 (kill-switch lease_reclaim_enabled=false) | PASS |
| TC-16 | unit | test_merge_gate.py | AC-11 (идемпотентность при уже слитом PR) | PASS |
| TC-17 | integration | test_merge_gate_race.py | AC-10 (докатывание незавершённого merge) | PASS |
| TC-18 | integration | test_queue.py | AC-15 (блок reaper в /queue) | PASS |
| TC-19 | unit | test_config.py | AC-13 (контракты STAGE_TRANSITIONS/QG_CHECKS неизменны) | PASS |
| TC-20 | unit | test_config.py | §5/AC-14 (новые настройки reaper_*/lease_reclaim_*) | PASS |
| TC-21 | unit | test_job_reaper.py | FR-2.1/AC-6 (стартовый реклейм в lifespan) | PASS |
Все 21 TC из плана — PASS.
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
- A (AC-1…AC-5): job-reaper — покрыты TC-01..TC-06, TC-09 → PASS
- B (AC-6…AC-9): lease-reclaim — покрыты TC-10..TC-15 → PASS
- C (AC-10, AC-11): идемпотентная финализация — TC-16, TC-17 → PASS
- D (AC-12 прод не трогается, AC-13 контракты, AC-14 kill-switches): TC-07, TC-15, TC-19, TC-20 + smoke прода без рестарта → PASS
- E (AC-15 /queue, AC-16 логи/алерты): TC-18 → PASS
- F (AC-17 документация): review подтвердил обновление README/internals/ADR-001/adr-0011/CHANGELOG/.env.example (APPROVED) → PASS
- G (AC-18 регресс зелёный): `pytest tests/` 747 passed → PASS
## Вывод pytest
### Целевые модули плана
```
$ python -m pytest tests/test_job_reaper.py tests/test_merge_lease_reclaim.py \
tests/test_merge_gate.py tests/test_merge_gate_race.py \
tests/test_queue.py tests/test_config.py -q
92 passed, 1 warning in 3.40s
```
### Полный регресс
```
$ python -m pytest tests/ -v --tb=short
======================= 747 passed, 1 warning in 15.47s ========================
```
(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-065,
предсуществующий.)
## Итог
**PASS.** Полный регресс — 747 passed, 0 failed. Все 21 TC тест-плана зелёные,
все критерии приёмки (AC-1…AC-18) подтверждены. Smoke прода — health/status/queue
200 OK, прод-контейнер не перезапускался (self-hosting инвариант соблюдён).
Задача готова к переходу на стадию `deploy-staging`.

View File

@@ -0,0 +1,32 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T16:13:48Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live staging environment (8501),
run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001).
**Verdict:** SUCCESS — `staging_check.py` exited **0**. All REAL (pipeline)
checks green; the only failures are the two known sandbox-infra checks
(C9a/C9b), which are waived per ORCH-061 because every REAL check passed.
## Result
- RESULT: 8/10 checks PASS
- REAL failed: none
- SANDBOX_INFRA failed (waived): C9a Branch appears in orchestrator-sandbox; C9b Analyst job enqueued in staging queue
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
## Block detail
- [Block A] SMOKE — A1 /health, A2 /queue, A3 ORCH_STAGING=true → PASS
- [Block B] ACCESS — B4 Plane sandbox, B5 Gitea orchestrator-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 → FAIL (sandbox-infra, waived)
- CLEANUP — Plane issue deleted (HTTP 204)
tolerance: staging_infra_tolerance_enabled=True

View File

@@ -0,0 +1,7 @@
# Business Request: [высокий] Статусная модель Plane: осмысленные статусы этапов
Work Item ID: ORCH-066
## Description
TBD

View File

@@ -0,0 +1,110 @@
# 01 — Business Requirements Document (BRD)
**Work Item:** ORCH-066
**Заголовок:** [высокий] Статусная модель Plane: осмысленные статусы этапов
**Стадия:** analysis
**Автор:** Analyst
**Дата:** 2026-06-07
---
## 1. Контекст и проблема
Статусная модель Plane оркестратора имеет **семантические перегрузки**: один и тот
же Plane-статус используется для несовместимых смыслов, из-за чего:
- оператор не понимает, на каком реально этапе стоит задача (доска нечитаема);
- повышается риск ошибки оператора (например, неверный ручной перевод статуса);
- `In Progress` одновременно означает «человек запускает конвейер», «идёт анализ»,
«идёт прод-деплой» и «возврат из Needs Input» — четыре разных смысла на одном статусе.
Уже частично исправлено: ORCH-059 ввёл отдельный статус для подтверждения деплоя
(`Confirm Deploy`), разгрузив перегруженный `Approved`. ORCH-066 завершает наведение
порядка по **утверждённой Owner** статусной модели.
### Два слоя (критично различать)
| Слой | Что это | Источник | Трогаем? |
|------|---------|----------|----------|
| **A** | `STAGE_TRANSITIONS` — внутренняя машина стадий (`created→analysis→…→done`) | `src/stages.py` | **НЕТ (инвариант)** |
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки в `src/stage_engine.py` / `src/webhooks/plane.py` | **ДА** |
ORCH-066 меняет **только слой B** и точки, где код вручную проставляет Plane-статусы.
---
## 2. Целевая статусная модель (решение Owner)
```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
- `[...]` = **действие человека** (вход-триггер).
- Остальное ставит **орк** (индикация).
### Ветки (нелинейные исходы)
- **Rejected** — откат на предыдущую стадию (человек).
- **Needs Input** — ТОЛЬКО аналитик (НЕ расширять на других агентов).
- **Blocked** — затык / фейл деплоя / деградация прода.
- **Cancelled** — человек решил не делать задачу (валидный выход из In Review).
---
## 3. Бизнес-требования
| ID | Требование | Приоритет |
|----|------------|-----------|
| **BR-1** | Каждый этап конвейера показывается на доске Plane осмысленным статусом (To Analyse / Analysis / Code-Review / Awaiting Deploy / Deploying / Monitoring after Deploy). | Must |
| **BR-2** | `To Analyse` — единый человеческий вход: (а) старт нового конвейера, (б) resume/relaunch аналитика при возврате из Needs Input. Заменяет роль `In Progress` как входа-триггера. | Must |
| **BR-3** | Стадия `analysis` индицируется отдельным статусом `Analysis` (орк ставит при старте/relaunch аналитика), а не `In Progress`. | Must |
| **BR-4** | Стадия `review` индицируется Plane-статусом `Code-Review` (переименование `Review`). | Must |
| **BR-5** | Self-deploy Phase A (approval-pending) ставит `Awaiting Deploy` вместо `In Review`. | Must |
| **BR-6** | Self-deploy Phase B (старт прод-деплоя) ставит `Deploying`. | Must |
| **BR-7** | Self-deploy Phase C (health-OK финализация) ставит `Monitoring after Deploy` (НЕ `Done` сразу). | Must |
| **BR-8** | Post-deploy monitor (ORCH-021): чистое закрытие окна (HEALTHY) → `Done`; UNHEALTHY/деградация → `Blocked`. | Must |
| **BR-9** | `In Review` разгрузить: оставить ТОЛЬКО за approve-pending артефактов конвейера (BRD/ревью). Выходы: `Approved` (вперёд), `Rejected` (откат), `Cancelled` (человек отменил). | Must |
| **BR-10** | `Needs Input` — БЕЗ ИЗМЕНЕНИЙ. Остаётся только у аналитика (`01-questions.md``set_issue_needs_input`). Механизм не трогать. | Must |
| **BR-11** | Возврат аналитика из Needs Input выполняется через `To Analyse` (а НЕ через `In Progress`). Логика fork «старт vs resume» (по наличию task + active-job) сохраняется. | Must (грабли R1) |
| **BR-12** | **Fail-closed:** отсутствие нового статуса в проекте (enduro / Plane API down / fallback `_DEFAULT_STATES`) НЕ приводит к падению; поведение остаётся backward-compatible (паттерн ORCH-059 AC-7). | Must |
| **BR-13** | Reconciler не «оживляет» активные ожидания (`Awaiting Deploy` / `Deploying` / `Monitoring after Deploy`) как зависшие задачи (Guard 2 skip-list). | Must |
| **BR-14** | Документация (golden source) обновлена в том же PR: `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md`, ADR per-work-item. | Must |
---
## 4. Границы (Out of Scope / НЕ трогать)
- `STAGE_TRANSITIONS` (`src/stages.py`) — машина стадий, инвариант.
- `QG_CHECKS`, `check_deploy_status`, exit-коды хука (0/1/2), merge-gate, схема БД.
- `Confirm Deploy` (уже работает, ORCH-059).
- Механизм `Needs Input` (analyst-only) — не расширять, не менять.
- Поведение прод-деплоя **не-self** репозиториев (enduro-trails): для них терминальный
переход остаётся `deploy → Done` как сейчас (Monitoring after Deploy не применяется —
post-deploy monitor армится только для self-hosting).
- Автоматический approve / авто-rollback self-hosting (ORCH-54 / ORCH-021 политика
ALERT_ONLY) — не меняется.
---
## 5. Инфра-предусловие (вне кода, делает оператор)
Новые Plane-статусы в проекте **ORCH** создаёт оператор через Plane API **ДО** эксплуатации:
`To Analyse`, `Analysis`, `Code-Review`, `Awaiting Deploy`, `Deploying`,
`Monitoring after Deploy` (`Confirm Deploy` уже есть).
Резолвер (`_PLANE_NAME_TO_KEY` + `get_project_states`) подхватывает их **по имени** с
**fail-closed fallback** на `_DEFAULT_STATES` (см. BR-12). Документируется в
`07-infra-requirements.md` (создаёт архитектор) и в `docs/operations/`.
---
## 6. Definition of Done
- Plane показывает осмысленные статусы на каждом этапе.
- Возврат аналитика из Needs Input работает через `To Analyse`.
- Phase A → `Awaiting Deploy`, Phase B → `Deploying`, Phase C → `Monitoring after Deploy`,
окно HEALTHY → `Done`, фейл → `Blocked`.
- `STAGE_TRANSITIONS` не изменён.
- `pytest tests/ -q` — зелёный. Fail-closed покрыт тестами.
- Документация обновлена.

View File

@@ -0,0 +1,178 @@
# 02 — Техническое задание (ТЗ)
**Work Item:** ORCH-066
**Стадия:** analysis → (вход для architecture)
**Автор:** Analyst
> ТЗ фиксирует ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые точки кода. Конкретную архитектуру
> резолвера (точные имена ключей/функций) финализирует архитектор в ADR. Ниже —
> опорный контракт, согласованный с бизнес-запросом Owner.
---
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче |
|--------|---------------|
| `src/plane_sync.py` | **Ядро изменений (слой B):** реестр логических статусов (`_DEFAULT_STATES`), `_PLANE_NAME_TO_KEY`, маппинг стадия→статус (`_STAGE_TO_STATE_KEY`, `STAGE_VISIBILITY_STATE`), хелперы `set_issue_*`. |
| `src/webhooks/plane.py` | Маршрутизация входящего статуса (`handle_issue_updated`): `To Analyse``handle_status_start` (старт **или** resume). |
| `src/stage_engine.py` | Точки ручной простановки статуса: analyst-flow (`Analysis`/`Needs Input`/`In Review`), Phase A (`Awaiting Deploy`), Phase B (`Deploying`), Phase C → `Monitoring after Deploy`, post-deploy monitor → `Done`/`Blocked`. |
| `src/reconciler.py` | F-2 запрос статусов (`To Analyse` в список), Guard 2 skip-list (активные ожидания). |
| `src/stages.py` | **НЕ менять** (инвариант слоя A). Используется только для чтения переходов. |
| `src/config.py` | (При необходимости) kill-switch для новой статусной модели — на усмотрение архитектора (см. §6). |
---
## 2. Изменения статусной модели (слой B)
### 2.1. Реестр логических статусов (`src/plane_sync.py`)
Ввести новые **логические ключи** и их имена в `_PLANE_NAME_TO_KEY`:
| Логический ключ | Plane name | Назначение |
|-----------------|-----------|------------|
| `to_analyse` | `To Analyse` | Вход-триггер (старт + resume аналитика). |
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review`. |
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |
Сохранить существующие: `backlog`, `todo`, `in_progress` (backward-compat), `needs_input`,
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `testing`,
`approved`, `rejected`. `Cancelled` уже присутствует в `_PLANE_NAME_TO_KEY`.
### 2.2. Fail-closed резолюция (КРИТИЧНО — BR-12)
`get_project_states()` после резолва по API делает `setdefault(k, v)` из `_DEFAULT_STATES`.
Чтобы отсутствие нового статуса в проекте (enduro / Plane down / частичная конфигурация)
**не ломало** конвейер, новые логические ключи в `_DEFAULT_STATES` должны
**алиаситься на существующие UUID** (degrade-to-current):
| Новый ключ | Default-алиас (UUID) | Деградированное поведение |
|------------|----------------------|---------------------------|
| `to_analyse` | = `in_progress` | enduro/старый проект: `In Progress` по-прежнему триггерит старт/resume. |
| `analysis` | = `in_progress` | analysis показывается как `In Progress` (как сейчас). |
| `code_review` | = `review` | review показывается как `Review` (как сейчас). |
| `awaiting_deploy` | = `in_review` | Phase A показывается как `In Review` (как сейчас). |
| `deploying` | = `in_progress` | Phase B показывается как `In Progress` (как сейчас). |
| `monitoring` | = `done` | Phase C показывается как `Done` (как сейчас); монитор затем держит Done / флипает Blocked. |
> Эффект: если оператор НЕ создал новый статус — система работает строго как до ORCH-066
> (никаких падений, никаких 404 от Plane PATCH). Если создал — резолвится по имени и
> используется новый UUID. Это ровно паттерн ORCH-059 AC-7.
### 2.3. Маппинг стадия → статус
`src/plane_sync.py`:
- `_STAGE_TO_STATE_KEY`: `analysis``analysis` (было `in_progress`); `review``code_review`
(было `review`). `deploy` остаётся (управляется Phase A/B/C напрямую, не через
`update_issue_state`). `created`/`architecture`/`development`/`testing`/`done` — без изменений.
- `STAGE_VISIBILITY_STATE`: `review``code_review` (было `review`). Добавить
`analysis``analysis`, если индикация analysis ставится через `set_issue_stage_state`
(решает архитектор; альтернатива — отдельный хелпер `set_issue_analysis`).
- Сохранить совместимость `STAGE_TO_STATE` / `PLANE_STATES` алиасов (импортируются тестами).
### 2.4. Точки простановки статуса
| Место (файл:симв.) | Сейчас | Должно стать |
|--------------------|--------|--------------|
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress``handle_status_start` | `new_state == to_analyse` (с fail-closed: при алиасе совпадает с `in_progress`) → `handle_status_start` |
| `webhooks/plane.py` `start_pipeline` (старт) | статус остаётся `In Progress` | при старте/enqueue analyst орк ставит `Analysis` |
| `webhooks/plane.py` `handle_status_start` (resume из Needs Input) | relaunch на `In Progress`-триггере | relaunch на `To Analyse`-триггере; при relaunch орк ставит `Analysis`. Fork «старт vs resume» (по `get_task_by_plane_id` + `has_active_job_for_task`) — **сохранить как есть.** |
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | оставить `In Review` (BR-9: In Review только за approve-pending конвейера) ✔ без изменений |
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) |
| `stage_engine.py` `_handle_self_deploy_phase_a` | `set_issue_in_review` | `Awaiting Deploy` (`set_issue_awaiting_deploy` или аналог) |
| `stage_engine.py` `_handle_self_deploy_phase_b` | (статус не меняет) | `Deploying` |
| `stage_engine.py` advance `deploy → done` (terminal-sync, строка ~338) | `set_issue_done` для всех | **self-hosting:** `Monitoring after Deploy` (перед/вместо арма монитора); **не-self:** `Done` как сейчас |
| `stage_engine.py` `run_post_deploy_monitor` (HEALTHY, окно закрыто) | пишет лог + коммент, статус Plane НЕ трогает (остаётся Done) | `Done` (явно) |
| `stage_engine.py` `run_post_deploy_monitor` (DEGRADED) | пишет лог + alert | `Blocked` |
> **Замечание по terminal-sync (важно для архитектора):** сейчас `advance_stage` на
> `next_stage == "done"` вызывает `set_issue_done` безусловно (строка ~338), затем армит
> post-deploy monitor для self-hosting (~361). Нужно развести: для репо, где
> `post_deploy.post_deploy_applies(repo)` истинно (self-hosting) — ставить `Monitoring
> after Deploy` вместо `Done`, и переложить простановку `Done`/`Blocked` на финал
> монитора (`run_post_deploy_monitor`). Для прочих репо — `Done` как сейчас.
### 2.5. Новые хелперы `src/plane_sync.py`
Добавить тонкие обёртки по образцу `set_issue_in_review` (резолв per-project UUID +
`_set_issue_state_direct`), never-raise при отсутствии issue:
- `set_issue_analysis(work_item_id, project_id=None)`
- `set_issue_code_review(...)` (или через `set_issue_stage_state("review")`)
- `set_issue_awaiting_deploy(...)`
- `set_issue_deploying(...)`
- `set_issue_monitoring(...)`
(Точный набор/именование — на усмотрение архитектора; контракт: per-project резолв +
fail-closed.)
---
## 3. Изменения reconciler (`src/reconciler.py`)
- **F-2** `_reconcile_plane_project`: добавить `to_analyse` в список запрашиваемых
статусов (`list_issues_by_state([... , to_analyse])`) и в `_reconcile_plane_issue`
маршрутизировать `new_state == to_analyse``handle_status_start` (старт при `task is
None`, resume при существующем task без active-job — логика уже в `handle_status_start`).
Сохранить обработку `approved`/`rejected`. При fail-closed алиасе `to_analyse==in_progress`
поведение не дублируется (один и тот же UUID).
- **Guard 2** `_is_blocked_or_needs_input` (F-1 skip): расширить skip-множество активными
ожиданиями — `awaiting_deploy`, `deploying`, `monitoring` — чтобы реконсилер НЕ
«оживлял» их как зависшие (BR-13). Имя метода/семантику можно обобщить
(«human-or-active-wait»), флаг `reconcile_skip_blocked_enabled` продолжает управлять
этим networked-чеком.
> Примечание: F-1 и так не тронет Phase A (`check_deploy_status` red → silent),
> Deploying (active finalizer job), Monitoring (стадия `done`). Guard 2 — явная
> defense-in-depth по требованию Owner.
---
## 4. Изменения API / эндпоинтов
**Нет** новых HTTP-эндпоинтов. `GET /queue` / `GET /status` — без изменений контракта
(статусы Plane там не отражаются). Изменения только во внешней индикации Plane (PATCH
issue state — существующий механизм).
---
## 5. Изменения схемы БД
**Нет.** `tasks` не хранит Plane-статус (источник истины — стадия в БД + Plane API).
Миграции не требуются.
---
## 6. Требования к новым QG checks
**Нет.** `QG_CHECKS` не расширяется. Статусы — индикация, не управление (канон:
машинные вердикты читаются из YAML-frontmatter артефактов, не из Plane-статуса).
Опционально (на усмотрение архитектора): единый kill-switch новой статусной модели
(env-флаг) для безопасного раската, по образцу `staging_infra_tolerance_enabled` /
`reconcile_skip_blocked_enabled`. Не обязателен, т.к. fail-closed алиасинг (§2.2) уже даёт
backward-compatible деградацию.
---
## 7. Артефакты pipeline, создаваемые/обновляемые
- `06-adr/ADR-001-plane-status-model.md` — архитектор (решение по резолверу,
алиасингу, разводке terminal-sync).
- `07-infra-requirements.md` — архитектор (список Plane-статусов для ручного создания
оператором + Plane API инструкция).
- Документация (golden source, тот же PR): `CLAUDE.md` (секция статусной модели),
`docs/architecture/README.md` (секция статусов рядом с ORCH-036/ORCH-021),
`CHANGELOG.md`.
---
## 8. Инварианты (проверяемые)
- `src/stages.py` `STAGE_TRANSITIONS` — байт-в-байт без изменений.
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate,
схема БД, `Confirm Deploy`, механизм `Needs Input` — без изменений.
- Все новые `set_issue_*` / резолв — never-raise (Plane down ⇒ degrade, не crash).
- Поведение enduro (не-self) и его терминальный `Done` — без регресса.

View File

@@ -0,0 +1,71 @@
# 03 — Критерии приёмки (Acceptance Criteria)
**Work Item:** ORCH-066
Каждый критерий — чёткое условие PASS/FAIL. Покрытие тестами — см. `04-test-plan.yaml`.
---
## Группа A — Вход и стадия анализа
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-1** | `To Analyse` запускает конвейер | Перевод issue без task в `To Analyse``handle_status_start``start_pipeline` (создаётся task, ветка, enqueue analyst). | Не запускается / запускается на другом статусе. |
| **AC-2** | `To Analyse` делает resume аналитика из Needs Input | Существующий task без active-job + перевод в `To Analyse` → relaunch агента текущей стадии (analyst читает свежие комменты). Fork «старт vs resume» определяется по `get_task_by_plane_id` + `has_active_job_for_task` (как раньше). | Создаётся второй task / двойной запуск / resume не происходит. |
| **AC-3** | Стадия `analysis` индицируется статусом `Analysis` | При старте/relaunch аналитика орк ставит `Analysis`. | Остаётся `In Progress` (при наличии статуса `Analysis` в проекте). |
| **AC-4** | Busy-guard сохранён | `To Analyse` при существующем active-job для task → НЕ relaunch (no double launch). | Двойной запуск агента. |
## Группа B — Code-Review
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-5** | Стадия `review` индицируется `Code-Review` | Вход в стадию `review` → Plane-статус `Code-Review`. | Остаётся `Review` (при наличии нового статуса). |
## Группа C — Self-deploy фазы
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-6** | Phase A → `Awaiting Deploy` | `_handle_self_deploy_phase_a` ставит `Awaiting Deploy` (не `In Review`). | Ставит `In Review` (при наличии нового статуса). |
| **AC-7** | Phase B → `Deploying` | `_handle_self_deploy_phase_b` при успешном `initiate_deploy` ставит `Deploying`. | Статус не меняется / иной. |
| **AC-8** | Phase C → `Monitoring after Deploy` (self) | Финализатор SUCCESS для self-hosting → статус `Monitoring after Deploy`, НЕ `Done` сразу. | Ставит `Done` немедленно (для self-hosting). |
| **AC-9** | Не-self deploy → `Done` без регресса | Для не-self репо (`post_deploy_applies==False`) терминальный `deploy → done` ставит `Done` как сейчас. | Не-self репо получает `Monitoring after Deploy` / иной регресс. |
## Группа D — Post-deploy monitor
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-10** | Чистое окно → `Done` | `run_post_deploy_monitor` HEALTHY + окно исчерпано → статус `Done`. | Остаётся `Monitoring after Deploy` / иной. |
| **AC-11** | Деградация → `Blocked` | `run_post_deploy_monitor` DEGRADED → статус `Blocked` (+ существующий ALERT_ONLY для self). | Остаётся в Monitoring / ставит Done. |
| **AC-12** | Self-hosting монитор не рестартит прод | Тик НИКОГДА не рестартит/откатывает прод-контейнер (ORCH-021 BR-5 сохранён). | Тик трогает прод-контейнер. |
## Группа E — In Review / Needs Input / ветки
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-13** | `In Review` только за approve-pending конвейера | `In Review` ставится лишь для approve артефактов (analyst BRD/ревью), не для Phase A. | Phase A / иные стадии ставят `In Review`. |
| **AC-14** | `Needs Input` без изменений | Поведение `set_issue_needs_input` (analyst `01-questions.md`) идентично прежнему; не расширено на других агентов. | Механизм изменён / расширен. |
| **AC-15** | `Cancelled` — валидный выход из In Review без действий конвейера | Перевод в `Cancelled` → орк не выполняет advance/rollback (индикация, не управление). | Орк совершает действие конвейера на `Cancelled`. |
## Группа F — Fail-closed (КРИТИЧНО)
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-16** | Отсутствие нового статуса не ломает конвейер | Проект без новых статусов (enduro/частичный/Plane down) → `get_project_states` отдаёт default-алиасы; все `set_issue_*`/триггеры работают backward-compatible, без исключений и без 404 PATCH. | Падение / необработанное исключение / зависание задачи. |
| **AC-17** | enduro `In Progress` по-прежнему стартует конвейер | Через `to_analyse`-алиас (= `in_progress` UUID) перевод enduro-issue в `In Progress` запускает старт/resume. | enduro-старт сломан. |
| **AC-18** | Резолв по имени | При наличии статуса в проекте по `name` (`_PLANE_NAME_TO_KEY`) используется его UUID, а не default-алиас. | Используется неверный UUID. |
## Группа G — Reconciler
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-19** | F-2 реконсилирует `To Analyse` | `_reconcile_plane_project` запрашивает `to_analyse` и маршрутизирует к `handle_status_start` (старт/resume при потерянном webhook). | `To Analyse`-старты не реконсилируются. |
| **AC-20** | Guard 2 skip активных ожиданий | Задачи в `Awaiting Deploy` / `Deploying` / `Monitoring after Deploy` НЕ «оживляются» F-1 как зависшие. | Реконсилер advance'ит активное ожидание. |
## Группа H — Инварианты и документация
| ID | Критерий | PASS | FAIL |
|----|----------|------|------|
| **AC-21** | `STAGE_TRANSITIONS` не изменён | `src/stages.py` `STAGE_TRANSITIONS` идентичен (diff пуст). | Любое изменение слоя A. |
| **AC-22** | Реестры/контракты не изменены | `QG_CHECKS`, `check_deploy_status`, exit-коды хука, merge-gate, схема БД, `Confirm Deploy` — без изменений. | Любое изменение перечисленного. |
| **AC-23** | Тесты зелёные | `pytest tests/ -q` проходит полностью; новые fail-closed тесты присутствуют и зелёные. | Любой красный тест. |
| **AC-24** | Документация обновлена (golden source) | `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` обновлены; заведён `06-adr/ADR-001-*`. | Любой из артефактов не обновлён. |

View File

@@ -0,0 +1,184 @@
work_item: ORCH-066
description: >
Тест-план статусной модели Plane (слой B). Покрывает осмысленные статусы этапов,
возврат аналитика через To Analyse, фазы self-deploy, post-deploy monitor,
fail-closed деградацию и reconciler. Слой A (STAGE_TRANSITIONS) проверяется на
неизменность. Все тесты — pytest; Plane API мокается (httpx), как в существующих
tests/test_plane_*.py / tests/test_orch10_states.py.
tests:
# --- Группа A: вход и стадия анализа ---
- id: TC-01
type: unit
description: "To Analyse без существующего task -> handle_status_start -> start_pipeline (старт конвейера)."
module: tests/test_status_trigger.py
covers: [AC-1]
expected: PASS
- id: TC-02
type: integration
description: "To Analyse при существующем task без active-job -> relaunch агента стадии (resume из Needs Input), новый task НЕ создаётся."
module: tests/test_plane_to_analyse_resume.py
covers: [AC-2, BR-11]
expected: PASS
- id: TC-03
type: unit
description: "Старт/relaunch аналитика ставит Plane-статус Analysis (а не In Progress) при наличии статуса в проекте."
module: tests/test_plane_status_model.py
covers: [AC-3]
expected: PASS
- id: TC-04
type: unit
description: "To Analyse при существующем task с active-job -> НЕ relaunch (busy-guard)."
module: tests/test_plane_to_analyse_resume.py
covers: [AC-4]
expected: PASS
# --- Группа B: Code-Review ---
- id: TC-05
type: unit
description: "Вход в стадию review -> Plane-статус Code-Review (маппинг _STAGE_TO_STATE_KEY / STAGE_VISIBILITY_STATE)."
module: tests/test_plane_status_model.py
covers: [AC-5]
expected: PASS
# --- Группа C: self-deploy фазы ---
- id: TC-06
type: unit
description: "_handle_self_deploy_phase_a ставит Awaiting Deploy (не In Review)."
module: tests/test_deploy_approve.py
covers: [AC-6, AC-13]
expected: PASS
- id: TC-07
type: unit
description: "_handle_self_deploy_phase_b при успешном initiate_deploy ставит Deploying."
module: tests/test_deploy_approve.py
covers: [AC-7]
expected: PASS
- id: TC-08
type: integration
description: "Phase C (finalizer SUCCESS) для self-hosting ставит Monitoring after Deploy, НЕ Done; армит post-deploy monitor."
module: tests/test_deploy_terminal_sync.py
covers: [AC-8]
expected: PASS
- id: TC-09
type: integration
description: "Не-self репо: deploy->done ставит Done (без регресса, Monitoring не применяется)."
module: tests/test_deploy_terminal_sync.py
covers: [AC-9]
expected: PASS
# --- Группа D: post-deploy monitor ---
- id: TC-10
type: unit
description: "run_post_deploy_monitor HEALTHY + окно исчерпано -> Plane-статус Done."
module: tests/test_post_deploy.py
covers: [AC-10]
expected: PASS
- id: TC-11
type: unit
description: "run_post_deploy_monitor DEGRADED -> Plane-статус Blocked (+ ALERT_ONLY для self)."
module: tests/test_post_deploy.py
covers: [AC-11]
expected: PASS
- id: TC-12
type: unit
description: "Self-hosting тик НЕ рестартит/не откатывает прод-контейнер (ORCH-021 BR-5 сохранён)."
module: tests/test_post_deploy.py
covers: [AC-12]
expected: PASS
# --- Группа E: In Review / Needs Input / Cancelled ---
- id: TC-13
type: unit
description: "In Review ставится только за approve-pending конвейера (analyst BRD ready), не Phase A."
module: tests/test_analyst_status_only_regression.py
covers: [AC-13]
expected: PASS
- id: TC-14
type: unit
description: "set_issue_needs_input (analyst 01-questions.md) поведение идентично прежнему; не расширено на других агентов."
module: tests/test_plane_status_model.py
covers: [AC-14, BR-10]
expected: PASS
- id: TC-15
type: unit
description: "Перевод в Cancelled -> handle_issue_updated не выполняет advance/rollback (индикация, не управление)."
module: tests/test_plane_webhook.py
covers: [AC-15]
expected: PASS
# --- Группа F: fail-closed (критично) ---
- id: TC-16
type: unit
description: "Проект без новых статусов: get_project_states отдаёт default-алиасы (to_analyse=in_progress, code_review=review, awaiting_deploy=in_review, monitoring=done); исключений нет."
module: tests/test_plane_status_failclosed.py
covers: [AC-16, BR-12]
expected: PASS
- id: TC-17
type: unit
description: "Plane API down -> get_project_states fallback на _DEFAULT_STATES; set_issue_* never-raise."
module: tests/test_plane_status_failclosed.py
covers: [AC-16]
expected: PASS
- id: TC-18
type: integration
description: "enduro In Progress по-прежнему стартует конвейер через to_analyse-алиас."
module: tests/test_plane_status_failclosed.py
covers: [AC-17]
expected: PASS
- id: TC-19
type: unit
description: "Резолв по имени: при наличии статуса в проекте используется его UUID, а не default-алиас."
module: tests/test_orch10_states.py
covers: [AC-18]
expected: PASS
# --- Группа G: reconciler ---
- id: TC-20
type: integration
description: "F-2 _reconcile_plane_project запрашивает to_analyse и маршрутизирует к handle_status_start (потерянный webhook старта/resume)."
module: tests/test_reconciler_plane.py
covers: [AC-19]
expected: PASS
- id: TC-21
type: unit
description: "Guard 2: задачи в Awaiting Deploy / Deploying / Monitoring after Deploy НЕ оживляются F-1 как зависшие."
module: tests/test_reconciler.py
covers: [AC-20, BR-13]
expected: PASS
# --- Группа H: инварианты ---
- id: TC-22
type: unit
description: "STAGE_TRANSITIONS не изменён (явная проверка ключей/значений слоя A)."
module: tests/test_plane_status_model.py
covers: [AC-21]
expected: PASS
- id: TC-23
type: unit
description: "QG_CHECKS реестр и check_deploy_status контракты не изменены."
module: tests/test_plane_status_model.py
covers: [AC-22]
expected: PASS
- id: TC-24
type: integration
description: "Полный прогон pytest tests/ -q зелёный (регрессия)."
module: tests/
covers: [AC-23]
expected: PASS

View File

@@ -0,0 +1,287 @@
# ADR-001: Осмысленная статусная модель Plane (слой B)
**Work Item:** ORCH-066
**Стадия:** architecture
**Автор:** Architect
**Дата:** 2026-06-07
**Статус:** Accepted
> Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на
> BRD (`01-brd.md`), ТЗ (`02-trz.md`), критерии приёмки (`03-acceptance-criteria.md`).
> Инфра-предусловие (статусы, создаваемые оператором) — `07-infra-requirements.md`,
> риски — `10-tech-risks.md`.
---
## 1. Контекст
Plane-доска оркестратора семантически перегружена: `In Progress` одновременно
означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат
из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного
перевода статуса. ORCH-059 уже разгрузил `Approved` отдельным `Confirm Deploy`;
ORCH-066 завершает наведение порядка по утверждённой Owner модели.
**Жёсткое разделение двух слоёв (инвариант проекта):**
| Слой | Что | Источник | ORCH-066 |
|------|-----|----------|----------|
| **A** | `STAGE_TRANSITIONS` — машина стадий | `src/stages.py` | **НЕ трогаем** |
| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки простановки | **меняем только это** |
Статус — **индикация, не управление**. Машинные вердикты по-прежнему читаются только
из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена
Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих
триггеров `To Analyse`/`Approved`/`Rejected`, которые и раньше были входами).
Целевая модель Owner:
```
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
```
`[...]` = действие человека (вход-триггер); остальное ставит орк (индикация).
Ветки: **Rejected** (откат), **Needs Input** (только аналитик), **Blocked** (затык/фейл
деплоя/деградация), **Cancelled** (человек отменил задачу).
---
## 2. Решение
### 2.1. Реестр логических статусов (`src/plane_sync.py`)
Вводим 6 новых **логических ключей**. Имена в `_PLANE_NAME_TO_KEY` (резолв по имени из
Plane API):
| Логический ключ | Plane name | Назначение |
|-----------------|-----------|------------|
| `to_analyse` | `To Analyse` | Вход-триггер: старт нового конвейера **и** resume аналитика из Needs Input. |
| `analysis` | `Analysis` | Индикация стадии analysis (орк). |
| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review` как видимый статус. |
| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). |
| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). |
| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). |
Существующие ключи сохраняются: `backlog`, `todo`, `in_progress`, `needs_input`,
`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `review`,
`testing`, `approved`, `rejected`. `Cancelled` уже присутствует.
### 2.2. Fail-closed резолюция — **project-relative alias-fallback** (КРИТИЧНО, BR-12)
ТЗ §2.2 предложил статические алиасы на enduro-UUID в `_DEFAULT_STATES`. Архитектурное
уточнение: для **частично сконфигурированного** проекта (оператор создал не все новые
статусы) статический enduro-UUID в orchestrator-проекте даст невалидный `state` → PATCH
422/404. Поэтому деградация делается **относительно того же проекта**, а не на чужой
UUID.
**Два уровня fallback в `get_project_states()` (success-path), строго в порядке:**
1. Резолв по имени из Plane API (как сейчас).
2. **Alias-fallback (новый):** для каждого отсутствующего нового ключа — UUID его
**базового ключа из этого же проекта**:
```python
_STATE_ALIAS_FALLBACK = {
"to_analyse": "in_progress",
"analysis": "in_progress",
"code_review": "review",
"awaiting_deploy": "in_review",
"deploying": "in_progress",
"monitoring": "done",
}
# после резолва по имени, ДО _DEFAULT_STATES.setdefault:
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
if new_key not in resolved and resolved.get(base_key):
resolved[new_key] = resolved[base_key]
```
3. `_DEFAULT_STATES.setdefault(...)` (как сейчас) — последний резерв для путей, где
API недоступен целиком (`if not project_id: return _DEFAULT_STATES`, полный провал
запроса). В `_DEFAULT_STATES` новые ключи ТОЖЕ добавляются (= enduro-UUID базового
ключа), чтобы любой caller всегда получал полный словарь и `states[key]` не кидал
`KeyError`.
**Эффект деградации:**
| Сценарий | Поведение |
|----------|-----------|
| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). |
| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → **собственный** базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. |
| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (`In Progress`/`Review`/`Done`). |
| Plane API down целиком | `_DEFAULT_STATES` (enduro-UUID) — без регресса относительно сегодняшнего поведения. |
Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все `set_issue_*` и
`_set_issue_state_direct` остаются **never-raise** (PATCH-исключение логируется, не
пробрасывается) — индикация деградирует, слой A не затрагивается.
### 2.3. Маппинг стадия → статус
- `_STAGE_TO_STATE_KEY` (живой путь `update_issue_state`→`stage_to_state`):
`analysis` → `analysis` (было `in_progress`); `review` → `code_review` (было `review`).
`deploy` остаётся `in_progress` (управляется Phase A/B/C напрямую). Остальные — без
изменений.
- `STAGE_VISIBILITY_STATE`: `review` → `code_review`; добавить `analysis` → `analysis`
(для консистентности; `set_issue_stage_state` сейчас dormant, но карта обновляется).
- `STAGE_TO_STATE` (legacy/test-only) — обновить `analysis`→`_DEFAULT_STATES["analysis"]`,
`review`→`_DEFAULT_STATES["code_review"]`. UUID-значения **байт-в-байт прежние** (это
алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют.
### 2.4. Новые хелперы `src/plane_sync.py`
Тонкие обёртки по образцу `set_issue_in_review` (per-project резолв + `_set_issue_state_direct`,
never-raise):
- `set_issue_analysis(work_item_id, project_id=None)`
- `set_issue_code_review(work_item_id, project_id=None)`
- `set_issue_awaiting_deploy(work_item_id, project_id=None)`
- `set_issue_deploying(work_item_id, project_id=None)`
- `set_issue_monitoring(work_item_id, project_id=None)`
`get_project_states` всегда возвращает полный словарь (см. §2.2), поэтому `[key]` не
кидает `KeyError`.
### 2.5. Точки простановки статуса (разводка)
| Файл:место | Сейчас | Должно стать | AC |
|------------|--------|--------------|----|
| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` → `handle_status_start` (при алиасе совпадает с `in_progress`) | AC-1, AC-17 |
| `webhooks/plane.py` `start_pipeline` (успешный старт) | статус остаётся `In Progress` | в конце старта орк ставит `set_issue_analysis` | AC-3 |
| `webhooks/plane.py` `handle_status_start` (resume-ветка) | relaunch агента стадии | при relaunch орк ставит `set_issue_analysis`; fork «старт vs resume» (`get_task_by_plane_id` + `has_active_job_for_task`) — **без изменений** | AC-2, AC-4 |
| `webhooks/plane.py` `_rollback_stage` (reject@analysis, ~583) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | **без изменений** (BR-9) | AC-13 |
| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | AC-14 |
| `stage_engine.py` rollback@analysis (architect conflict, ~669) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 |
| `stage_engine.py` `_handle_self_deploy_phase_a` (~1012) | `set_issue_in_review` | `set_issue_awaiting_deploy` | AC-6, AC-13 |
| `stage_engine.py` `_handle_self_deploy_phase_b` (после `INITIATED` marker) | статус не меняет | `set_issue_deploying` | AC-7 |
| `stage_engine.py` terminal-sync `deploy → done` (~338) | `set_issue_done` для всех | **self (`post_deploy_applies`):** `set_issue_monitoring`; **не-self:** `set_issue_done` как сейчас | AC-8, AC-9 |
| `stage_engine.py` `run_post_deploy_monitor` HEALTHY+окно закрыто (~1260) | статус не трогает | `set_issue_done` (явно) | AC-10 |
| `stage_engine.py` `run_post_deploy_monitor` DEGRADED (~1273) | alert/log | `set_issue_blocked` (+ существующий ALERT_ONLY) | AC-11 |
**Разводка terminal-sync (детально, AC-8/AC-9).** Текущий код безусловно зовёт
`set_issue_done` на `next_stage == "done"`, затем (для self) армит post-deploy monitor.
Разводим по `post_deploy.post_deploy_applies(repo)`:
```python
if next_stage == "done" and work_item_id:
if post_deploy.post_deploy_applies(repo):
set_issue_monitoring(work_item_id) # self: окно наблюдения, НЕ Done сразу
else:
set_issue_done(work_item_id) # не-self: терминальный Done как сейчас
# арм монитора (существующий блок ~361) — без изменений
```
Финальный `Done`/`Blocked` для self-hosting перекладывается на `run_post_deploy_monitor`.
При деградированном алиасе `monitoring==done` self-hosting показывает `Done` и затем
монитор держит `Done`/флипает `Blocked` — поведение идентично сегодняшнему.
**AC-12 (инвариант ORCH-021):** добавление `set_issue_blocked` в DEGRADED-ветку —
**только индикация**; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер
(self-hosting остаётся `ALERT_ONLY`). `set_issue_blocked` — Plane-PATCH, не действие над
контейнером.
**Cancelled (AC-15):** изменений кода НЕ требует. `handle_issue_updated` реагирует только
на `to_analyse`/`approved`/`rejected`; `Cancelled` падает в `else` → «no pipeline action».
Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим
кодом.
### 2.6. Reconciler (`src/reconciler.py`)
- **F-2 `_reconcile_plane_project`:** заменить триггер `in_progress` → `to_analyse` в
списке запрашиваемых статусов (`list_issues_by_state([to_analyse, approved, rejected])`)
и в `_reconcile_plane_issue` маршрутизировать `new_state == to_analyse` →
`handle_status_start`. При алиасе `to_analyse == in_progress` (enduro) поведение
идентично текущему (один UUID; `list_issues_by_state` дедуплицирует через `set`). AC-19.
- **Guard 2 `_is_blocked_or_needs_input`:** расширить skip-множество активными ожиданиями
`awaiting_deploy`/`deploying`/`monitoring` (BR-13, AC-20). **Анти-регресс enduro
(КРИТИЧНО):** новые ключи алиасятся на `in_review`/`in_progress`/`done`; добавить их в
skip «как есть» → на enduro `In Progress`/`Done`-задачи начнут ошибочно пропускаться
F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip **только когда
они РАЗЛИЧНЫ от базовых рабочих статусов проекта** (т.е. реально созданы):
```python
base_working = {states.get(k) for k in (
"backlog","todo","in_progress","in_review","review",
"architecture","development","testing","approved","rejected","done")}
extra_waits = {states.get("awaiting_deploy"),
states.get("deploying"),
states.get("monitoring")} - base_working - {None}
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
return cur in skip_set
```
Enduro (алиасы схлопываются в base) → `extra_waits == {}` → нулевой регресс. Orchestrator
(отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до
«human-or-active-wait»; флаг `reconcile_skip_blocked_enabled` продолжает гасить этот
networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A: `check_deploy_status`
red → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадия
`done` → не итерируется) — Guard 2 это defense-in-depth по требованию Owner.
### 2.7. Без kill-switch
Отдельный env-флаг новой модели **не вводится**. Раскат естественно гейтится
**инфра-предусловием**: пока оператор не создал новые статусы — alias-fallback (§2.2)
держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это
проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает
флаг как опциональный — сознательно отказываемся.)
---
## 3. Затронутые модули (карта изменений)
| Модуль | Изменение |
|--------|-----------|
| `src/plane_sync.py` | `_PLANE_NAME_TO_KEY` +6; `_DEFAULT_STATES` +6 (enduro-alias UUID); `_STATE_ALIAS_FALLBACK` (новое) + применение в `get_project_states`; `_STAGE_TO_STATE_KEY` (analysis/review); `STAGE_VISIBILITY_STATE`; `STAGE_TO_STATE` (legacy); 5 новых `set_issue_*`. |
| `src/webhooks/plane.py` | триггер `in_progress`→`to_analyse` в `handle_issue_updated`; `set_issue_analysis` в `start_pipeline` и resume-ветке `handle_status_start`; `_rollback_stage` reject@analysis → `set_issue_analysis`. |
| `src/stage_engine.py` | Phase A → `set_issue_awaiting_deploy`; Phase B → `set_issue_deploying`; terminal-sync split (`monitoring` vs `done`); post-deploy monitor HEALTHY→`set_issue_done`, DEGRADED→`set_issue_blocked`; rollback@analysis (architect conflict) `set_issue_in_progress`→`set_issue_analysis`. |
| `src/reconciler.py` | F-2 триггер `to_analyse`; Guard 2 skip-set + анти-регресс subtraction. |
| `src/stages.py` | **НЕ трогаем** (инвариант слоя A). |
| `src/config.py` | Без изменений (kill-switch не вводится). |
---
## 4. Инварианты (проверяемые, AC-21/AC-22)
- `src/stages.py` `STAGE_TRANSITIONS` — diff пуст (байт-в-байт).
- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука (0/1/2),
merge-gate, `check_branch_mergeable`/`check_staging_image_fresh`, схема БД — без изменений.
- `Confirm Deploy` (ORCH-059), механизм `Needs Input` (analyst-only) — без изменений.
- Новых HTTP-эндпоинтов нет; `GET /queue`/`GET /status` контракт без изменений.
- Миграций БД нет (`tasks` не хранит Plane-статус; источник истины — стадия в БД + Plane API).
- Все новые `set_issue_*` / резолв — never-raise.
- Не-self (enduro) терминальный `deploy → Done` — без регресса.
---
## 5. Последствия
**Плюсы**
- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены
от индикации.
- `In Progress` разгружен: больше не «всё подряд».
- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию,
ни конвейер.
- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting).
- Нет нового флага/таблицы → меньше движущихся частей.
**Минусы / ограничения**
- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до
этого orchestrator деградирует до старой индикации (см. `07-infra-requirements.md`).
- Статусы кэшируются per-process (`_STATES_CACHE`): после создания статусов нужен
`reload_project_states()` или рестарт **staging** (не прод — см. self-hosting риск).
- Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой
extra; orchestrator → три статуса).
**Self-hosting (⚠️):** изменения — слой B (Plane-индикация) + reconciler-гварды; машина
стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через `deploy-staging` (8501)
до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта.
---
## 6. Альтернативы (отклонены)
- **Статический enduro-UUID алиас (ТЗ §2.2 буквально):** ломается на частичной
конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative
alias-fallback (§2.2).
- **Глобальный env kill-switch новой модели:** избыточен — инфра-предусловие уже даёт
естественный гейт раската (§2.7).
- **Хранить Plane-статус в `tasks` (миграция БД):** не нужно; источник истины — стадия +
живой Plane API. Нарушило бы инвариант «без лишних зависимостей».
- **Менять `STAGE_TRANSITIONS` ради новых статусов:** запрещено (инвариант слоя A);
статусы — индикация, отделены от машины стадий.

View File

@@ -0,0 +1,96 @@
# 07 — Требования к инфраструктуре
**Work Item:** ORCH-066
**Автор:** Architect
**Дата:** 2026-06-07
> ORCH-066 не меняет топологию (контейнеры/порты/сеть — без изменений, см.
> `docs/operations/INFRA.md`). Единственное инфра-действие — создание новых
> Plane-статусов в проекте **ORCH** руками оператора через Plane API. Это
> **предусловие эксплуатации**, не часть кодового PR.
---
## 1. Что нужно сделать оператору (ДО эксплуатации новой модели)
Создать в Plane-проекте **ORCH** следующие статусы (states) с точными именами —
резолвер сопоставляет их по `name` (`_PLANE_NAME_TO_KEY`):
| Plane name (точно) | Логический ключ | Группа Plane (рекомендуемая) | Назначение |
|--------------------|-----------------|------------------------------|------------|
| `To Analyse` | `to_analyse` | unstarted / started | Человеческий вход: старт конвейера + resume аналитика из Needs Input. |
| `Analysis` | `analysis` | started | Индикация стадии анализа. |
| `Code-Review` | `code_review` | started | Индикация стадии review. |
| `Awaiting Deploy` | `awaiting_deploy` | started | Phase A: ожидание ручного approve на прод-деплой. |
| `Deploying` | `deploying` | started | Phase B: идёт прод-деплой. |
| `Monitoring after Deploy` | `monitoring` | started | Phase C / окно пост-деплой наблюдения. |
`Confirm Deploy` (ORCH-059) и базовые статусы (`Backlog`, `Todo`, `In Progress`,
`Architecture`, `Development`, `Review`, `Testing`, `Approved`, `Rejected`, `Done`,
`Cancelled`, `Needs Input`, `In Review`, `Blocked`) уже существуют — **не трогать**.
> ⚠️ **Точность имён критична.** Резолв идёт по строковому `name`. Опечатка/иной регистр
> → статус не сопоставится → ключ деградирует на собственный базовый UUID проекта
> (alias-fallback, ADR §2.2): индикация откатится к старому статусу, но конвейер
> продолжит работать. Дефис в `Code-Review` — обязателен.
---
## 2. Plane API — как создать статус
Эндпоинт (как в `src/plane_sync.py`, `PLANE_BASE = {plane_api_url}/api/v1`):
```
POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/
Headers: X-API-Key: <PLANE_API_TOKEN> (или соответствующий бот-токен с правами)
Body (JSON):
{ "name": "To Analyse", "group": "started", "color": "#3f76ff" }
```
Повторить для каждого имени из таблицы §1. `group` влияет только на колонку доски;
оркестратор `group` не читает (резолв строго по `name`). `color` — на вкус оператора.
Проверка после создания:
```
GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/
```
В ответе должны присутствовать все 6 имён.
---
## 3. Сброс кэша статусов (важно)
`get_project_states` кэширует резолв per-process (`_STATES_CACHE`). После создания
статусов оркестратор подхватит их **только** после сброса кэша:
- штатно — `plane_sync.reload_project_states(project_id)` (или рестарт процесса);
- на **staging** (8501) — безопасный рестарт песочницы;
- на **прод** (8500) — **НЕ рестартить контейнер ради этого** в рамках задачи
(self-hosting: общий контейнер всех проектов). Кэш заполняется при первом обращении к
проекту; если статусы созданы ДО первого PATCH в цикле новой версии — отдельный сброс не
нужен. Если созданы позже — дождаться штатного цикла обновления/деплоя орка.
---
## 4. Порядок раската (рекомендация)
1. Слить кодовый PR ORCH-066 через `deploy-staging` (8501).
2. Создать 6 статусов в проекте ORCH (§1§2).
3. Сбросить кэш / поднять staging, прогнать sandbox-задачу — убедиться, что доска
показывает `Analysis` / `Code-Review` / `Awaiting Deploy` / `Deploying` /
`Monitoring after Deploy` / `Done` на соответствующих этапах.
4. Прод-деплой орка штатным self-deploy (Phase A → approve → Phase B/C).
**До шага 2** система работает строго как до ORCH-066 (alias-fallback) — раскат
безопасно обратим: не создавать/удалить статусы = откат индикации к старой модели,
без изменения кода.
---
## 5. Что НЕ требуется
- Никаких изменений docker-compose, портов, сети, томов, `.env`/`.env.staging`.
- Никаких миграций БД (`tasks` не хранит Plane-статус).
- Никаких изменений в проекте **enduro-trails** — там новые статусы не создаются;
alias-fallback держит прежнюю индикацию (`In Progress`/`Review`/`Done`).

View File

@@ -0,0 +1,31 @@
# 10 — Технические риски
**Work Item:** ORCH-066
**Автор:** Architect
**Дата:** 2026-06-07
Риски слоя B (Plane-индикация). Слой A (`STAGE_TRANSITIONS`/гейты) не затрагивается, поэтому
класс «сломали конвейер» структурно исключён — худший исход любого риска ниже = неверная
**индикация**, не остановка конвейера.
| ID | Риск | Вероятность | Влияние | Митигация |
|----|------|-------------|---------|-----------|
| **R1** | Частичная конфигурация: оператор создал не все 6 статусов в ORCH → отсутствующий ключ деградирует. Наивный статический enduro-UUID дал бы невалидный `state` (PATCH 422) на orchestrator-issue. | Средняя | Средн. | **Project-relative alias-fallback** (ADR §2.2): отсутствующий ключ → собственный базовый UUID проекта → PATCH валиден, индикация откатывается к текущему статусу. Покрыть тестом partial-config. |
| **R2** | Enduro-регресс через Guard 2: новые ключи алиасятся на `in_progress`/`in_review`/`done`; наивное добавление в skip-set заставит F-1 пропускать enduro `In Progress`/`Done` → сломанная реконсиляция (ORCH-053/060). | Средняя | Высок. | **Subtraction базовых рабочих статусов** (ADR §2.6): `extra_waits -= base_working`. На enduro (алиасы схлопнуты) `extra_waits == {}` → нулевой регресс. Тест: enduro-алиас не добавляет skip, orchestrator-distinct добавляет. |
| **R3** | Двойной триггер старта: F-2 reconciler и webhook оба маршрутизируют `to_analyse`; при алиасе `to_analyse == in_progress` возможен повтор. | Низкая | Низк. | `list_issues_by_state` дедуплицирует UUID через `set`; active-job guard + atomic create-claim в `handle_status_start` (`get_task_by_plane_id` + `has_active_job_for_task`) — без двойного старта (AC-4). Сохранить fork как есть. |
| **R4** | Кэш статусов: после создания статусов `_STATES_CACHE` отдаёт старый резолв до сброса → доска не обновляется. | Средняя | Низк. | `reload_project_states()` / рестарт **staging**. Документировано в `07-infra-requirements.md §3`. Прод-рестарт ради кэша — запрещён (self-hosting). |
| **R5** | Опечатка в имени статуса оператором (`Code Review` без дефиса и т.п.) → ключ не резолвится. | Средняя | Низк. | Резолв по точному `name`; при промахе — alias-fallback (деградация, не падение). Точные имена и проверка в `07-infra-requirements.md §12`. |
| **R6** | Terminal-sync split: ошибка ветвления `post_deploy_applies` → enduro получает `Monitoring after Deploy` вместо `Done` (регресс AC-9) или self уходит в `Done` минуя окно (AC-8). | Низкая | Средн. | Единый источник условности — `post_deploy.post_deploy_applies(repo)` (та же функция, что армит монитор). Тесты AC-8 (self→monitoring) и AC-9 (не-self→done). |
| **R7** | Phase B: `set_issue_deploying` поставлен до фактического старта детача → ложная индикация при провале `initiate_deploy`. | Низкая | Низк. | Ставить `set_issue_deploying` **после** успешного `initiate_deploy` и записи `INITIATED` marker (ADR §2.5); провал `initiate_deploy` оставляет `Awaiting Deploy` + просьбу повторить approve. |
| **R8** | Post-deploy DEGRADED → `set_issue_blocked` ошибочно трактуется как «действие над продом». | Низкая | Высок.(если) | `set_issue_blocked` — только Plane-PATCH. Тик остаётся `ALERT_ONLY`, НИКОГДА не рестартит/откатывает прод-контейнер (AC-12, ORCH-021 BR-5). Явный тест: self DEGRADED не трогает контейнер. |
| **R9** | Plane API недоступен в момент простановки статуса → PATCH падает. | Низкая | Низк. | Все `set_issue_*`/`_set_issue_state_direct` — never-raise (логируют, не пробрасывают). Индикация пропускается, слой A не затронут. |
| **R10** | Регресс на тестах, читающих `STAGE_TO_STATE`/`PLANE_STATES` конкретные UUID. | Низкая | Низк. | Новые ключи в `_DEFAULT_STATES` = алиасы на те же in_progress/review/done UUID → значения байт-в-байт; `STAGE_TO_STATE` analysis/review остаются прежними UUID (ADR §2.3). |
| **R11** | Self-hosting: выкладка орка минуя staging. | Низкая | Высок. | Обязательный `deploy-staging` гейт (8501); прод не рестартить вне штатного self-deploy. Раскат обратим (не создавать статусы = старое поведение). |
## Сводный вывод
Все риски снижаемы в рамках принятой архитектуры; ни один не способен остановить конвейер
(слой A инвариантен). Два ключевых требуют аккуратной реализации и обязательных тестов:
**R1** (project-relative alias-fallback) и **R2** (Guard-2 anti-regress subtraction) —
оба зафиксированы в ADR §2.2 и §2.6 как явные контракты. Эскалации `arch:major-change` не
требуется: изменение локализовано в слое B, без новых компонентов/стадий/QG/миграций.

View File

@@ -0,0 +1,89 @@
---
type: review
work_item_id: ORCH-066
verdict: APPROVED
version: 1
---
# Review ORCH-066
## Summary
Осмысленная статусная модель Plane (слой B — индикация). Реализация затрагивает
строго слой B (`src/plane_sync.py`, точки простановки в `src/stage_engine.py` /
`src/webhooks/plane.py` / `src/reconciler.py`) и **не трогает слой A**
(`src/stages.py::STAGE_TRANSITIONS` — diff пуст). Все 4 оси проверки (ТЗ, ADR,
качество кода, тесты) и проверка документации — пройдены. `pytest tests/ -q`:
**774 passed**. Вердикт — **APPROVED**.
## Соответствие ТЗ (02-trz.md)
- §2.1 — 6 новых логических ключей в `_PLANE_NAME_TO_KEY` + `_DEFAULT_STATES`. ✔
- §2.2 — fail-closed резолюция (BR-12). ✔ (реализована усиленная project-relative
версия — см. ADR ниже).
- §2.3 — `_STAGE_TO_STATE_KEY` (analysis→analysis, review→code_review),
`STAGE_VISIBILITY_STATE`, legacy `STAGE_TO_STATE` (UUID байт-в-байт прежние). ✔
- §2.4 — точки простановки разведены (handle_issue_updated триггер `to_analyse`,
start_pipeline/resume → Analysis, Phase A → Awaiting Deploy, Phase B → Deploying,
terminal-sync split, post-deploy HEALTHY→Done / DEGRADED→Blocked,
rollback@analysis → Analysis). ✔
- §2.5 — 5 новых never-raise хелперов `set_issue_*`. ✔
- §3 — reconciler F-2 триггер `to_analyse` (+ resume-ветка), Guard 2 skip-set с
вычитанием base_working. ✔
- §4/§5/§6 — нет новых эндпоинтов, нет миграций БД, `QG_CHECKS` не расширен. ✔
## Соответствие ADR (06-adr/ADR-001)
- §2.2 project-relative alias-fallback (`_STATE_ALIAS_FALLBACK`, применён ДО
`_DEFAULT_STATES.setdefault`) — реализован точно по контракту, деградация на
собственный базовый UUID проекта, PATCH остаётся валидным на частичной
конфигурации. ✔
- §2.5 terminal-sync split по `post_deploy.post_deploy_applies(repo)` — реализован
как в ADR (self → Monitoring, не-self → Done). ✔
- §2.6 Guard 2 анти-регресс (extra_waits base_working {None}) — реализован
дословно, enduro-алиасы схлопываются → нулевой регресс. ✔
- §2.7 без kill-switch — config.py не изменён (diff пуст). ✔
## Качество кода
- Все новые `set_issue_*` следуют образцу `set_issue_in_review` (per-project резолв
+ `_set_issue_state_direct`), контракт never-raise сохранён, есть docstrings. ✔
- Post-deploy/terminal-sync простановки обёрнуты в try/except с warning-логом
(never break the tick). ✔
- Переменные в scope корректны (`work_item_id` определён до всех новых вызовов в
`start_pipeline`/`handle_status_start`/stage_engine). ✔
- AC-12 соблюдён: `set_issue_blocked` в DEGRADED-ветке — только индикация, тик
прод-контейнер не трогает. ✔
## Качество тестов
- Содержательные, не тривиальные: `test_plane_status_failclosed.py`
(TC-16/17/18 — partial project, API down, never-raise сеттеров, enduro alias
старт), `test_plane_to_analyse_resume.py`, `test_plane_status_model.py`,
`test_deploy_terminal_sync.py` (self/не-self split), `test_post_deploy_integration.py`,
`test_reconciler*.py` (F-2 to_analyse + Guard 2). ✔
## Инварианты (AC-21/AC-22)
- `src/stages.py` — diff 0 строк (STAGE_TRANSITIONS байт-в-байт). ✔
- `src/qg/checks.py` — diff 0 строк (QG_CHECKS, check_deploy_status). ✔
- `src/config.py` — diff 0 строк. ✔
- Схема БД — без миграций. ✔
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
## Документация
Обновлена в том же PR (golden source соблюдён):
- `CLAUDE.md` — добавлена секция «Статусная модель Plane (ORCH-066)». ✔
- `docs/architecture/README.md` — секция «Осмысленная статусная модель Plane
(ORCH-066)» + обновлён статусный footer. ✔
- `CHANGELOG.md` — подробная запись в [Unreleased]/Added. ✔
- `06-adr/ADR-001-plane-status-model.md` — заведён. ✔
- `07-infra-requirements.md` — присутствует (инфра-предусловие: 6 Plane-статусов
создаёт оператор). ✔
Изменения `src/` полностью отражены в документации → требование
«документация обновлена при изменении src/» выполнено.

View File

@@ -0,0 +1,77 @@
---
type: test-report
work_item_id: ORCH-066
result: PASS
---
# Test Report — ORCH-066
Осмысленная статусная модель Plane (слой B — индикация). Прогон полного регресса +
покрытие тест-плана `04-test-plan.yaml` + проверка инвариантов слоя A.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: feature/ORCH-066-plane
- Дата: 2026-06-07
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Покрывает | Описание | Модуль | Результат |
|-------|-----------|----------|--------|-----------|
| TC-01 | AC-1 | To Analyse без task → start_pipeline | test_status_trigger.py | PASS |
| TC-02 | AC-2,BR-11 | To Analyse resume аналитика, без двойного task | test_plane_to_analyse_resume.py | PASS |
| TC-03 | AC-3 | Старт/relaunch → статус Analysis | test_plane_status_model.py | PASS |
| TC-04 | AC-4 | Busy-guard: active-job → не relaunch | test_plane_to_analyse_resume.py | PASS |
| TC-05 | AC-5 | review → статус Code-Review | test_plane_status_model.py | PASS |
| TC-06 | AC-6,AC-13 | Phase A → Awaiting Deploy (не In Review) | test_deploy_approve.py | PASS |
| TC-07 | AC-7 | Phase B → Deploying | test_deploy_approve.py | PASS |
| TC-08 | AC-8 | Phase C self → Monitoring after Deploy | test_deploy_terminal_sync.py | PASS |
| TC-09 | AC-9 | Не-self deploy→done → Done (без регресса) | test_deploy_terminal_sync.py | PASS |
| TC-10 | AC-10 | Post-deploy HEALTHY → Done | test_post_deploy.py | PASS |
| TC-11 | AC-11 | Post-deploy DEGRADED → Blocked | test_post_deploy.py | PASS |
| TC-12 | AC-12 | Self-тик не рестартит прод | test_post_deploy.py | PASS |
| TC-13 | AC-13 | In Review только за approve-pending | test_analyst_status_only_regression.py | PASS |
| TC-14 | AC-14,BR-10 | Needs Input без изменений | test_plane_status_model.py | PASS |
| TC-15 | AC-15 | Cancelled → нет действий конвейера | test_plane_webhook.py | PASS |
| TC-16 | AC-16,BR-12 | Fail-closed default-алиасы, нет исключений | test_plane_status_failclosed.py | PASS |
| TC-17 | AC-16 | Plane API down → fallback, never-raise | test_plane_status_failclosed.py | PASS |
| TC-18 | AC-17 | enduro In Progress стартует через алиас | test_plane_status_failclosed.py | PASS |
| TC-19 | AC-18 | Резолв по имени → корректный UUID | test_orch10_states.py | PASS |
| TC-20 | AC-19 | F-2 реконсилирует To Analyse | test_reconciler_plane.py | PASS |
| TC-21 | AC-20,BR-13 | Guard 2 skip активных ожиданий | test_reconciler.py | PASS |
| TC-22 | AC-21 | STAGE_TRANSITIONS не изменён | test_plane_status_model.py | PASS |
| TC-23 | AC-22 | QG_CHECKS/check_deploy_status не изменены | test_plane_status_model.py | PASS |
| TC-24 | AC-23 | Полный регресс pytest зелёный | tests/ | PASS |
Все 24 тест-кейса — PASS.
## Инварианты слоя A (AC-21 / AC-22)
Diff против `origin/main` (merge-base `4815e378`):
- `src/stages.py` (STAGE_TRANSITIONS) — diff пуст ✔
- `src/qg/checks.py` (QG_CHECKS, check_deploy_status) — diff пуст ✔
- `src/config.py` (без kill-switch) — diff пуст ✔
## Smoke test API (TestClient — прод-контейнер 8500 не трогался)
> `curl` в окружении недоступен; smoke прогнан через FastAPI TestClient (lifespan),
> без рестарта/обращения к прод-контейнеру (self-hosting safety).
| Endpoint | Статус | Тело (фрагмент) |
|----------|--------|-----------------|
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
| GET /status | 200 | `{"active_tasks":[...]}` |
| GET /queue | 200 | `{"counts":{...},"max_concurrency":1,...}` |
## Вывод pytest
```
======================= 774 passed, 1 warning in 17.68s ========================
```
(единственный warning — PydanticDeprecatedSince20 в src/config.py, предсуществующий,
не связан с ORCH-066)
Прогон по модулям тест-плана: `117 passed` (ORCH-066-специфичные файлы).
## Итог
PASS — все тесты зелёные (774 passed), все 24 TC покрыты, инварианты слоя A
сохранены (diff пуст), smoke-эндпоинты отвечают 200. Review-вердикт APPROVED.
Задача готова к переходу на стадию deploy-staging.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-066
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
timestamp: 2026-06-07T22:01:57Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
run canonically via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`.
**Result: 8/10 checks PASS — exit code 0 (advance).**
All REAL (pipeline) checks are green. The two failing checks are the known
SANDBOX_INFRA-only checks C9a/C9b (sandbox branch / analyst-job — depend on
SANDBOX bot accounts being project members, not on the pipeline), which are
waived under ORCH-061 since every REAL check passed.
```
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
```
## Check breakdown
| Block | Check | Result |
|-------|-------|--------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 with counts/max_concurrency/resilience | PASS |
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
| B ACCESS | B4 Plane: sandbox project accessible | PASS |
| B ACCESS | B5 Gitea: orchestrator-sandbox accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
| C E2E | C9a Branch appears in orchestrator-sandbox | FAIL (waived — sandbox-infra) |
| C E2E | C9b Analyst job enqueued in staging queue | FAIL (waived — sandbox-infra) |
CLEANUP completed: test Plane issue deleted (HTTP 204); no branch to delete.

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-066
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

@@ -51,6 +51,46 @@ import datetime
import urllib.request
import urllib.error
import urllib.parse
from collections import namedtuple
# ---------------------------------------------------------------------------
# ORCH-061: pure staging-verdict logic (classification + infra-tolerant verdict).
# Imported from src.staging_verdict — a stdlib-only leaf, safe to import inside
# the orchestrator-staging container (PYTHONPATH=/app, pattern B6 / ORCH-048).
# Guarded so the suite still runs (in strict mode) if src is somehow unimportable
# from a host invocation; the fallback NEVER yields a silent green (fail-closed).
# ---------------------------------------------------------------------------
try:
from src.staging_verdict import ( # type: ignore
classify_check as _classify_check,
compute_staging_verdict as _compute_staging_verdict,
REAL as _REAL,
SANDBOX_INFRA as _SANDBOX_INFRA,
)
except Exception: # pragma: no cover - exercised only on a broken host import
_classify_check = None
_compute_staging_verdict = None
_REAL = "real"
_SANDBOX_INFRA = "sandbox_infra"
_FallbackVerdict = namedtuple("StagingVerdict", "status exit_code waived summary")
def _classify(label: str) -> str:
"""Classify a check label via staging_verdict; fail-closed to REAL if absent."""
if _classify_check is not None:
return _classify_check(label)
return _REAL
def _verdict(items, infra_tolerant: bool):
"""Compute the suite verdict via staging_verdict; strict fail-closed fallback."""
if _compute_staging_verdict is not None:
return _compute_staging_verdict(items, infra_tolerant)
failed = [lbl for (lbl, ok, _cat) in items if not ok]
if failed:
return _FallbackVerdict("FAILED", 1, [], f"FAILED (strict fallback): {failed}")
return _FallbackVerdict("SUCCESS", 0, [], "SUCCESS (strict fallback): all green")
# ---------------------------------------------------------------------------
# Colour helpers
@@ -152,23 +192,47 @@ def _sign_payload(secret: str, body: bytes) -> str:
class Results:
def __init__(self):
# _items keeps the (label, passed, detail) 3-tuple shape that existing
# ORCH-048 B6 tests unpack — categories live in a PARALLEL list so the
# public tuple contract is unchanged.
self._items: list[tuple[str, bool, str]] = [] # (label, passed, detail)
self._categories: list[str] = [] # ORCH-061: REAL | SANDBOX_INFRA
def add(self, label: str, passed: bool, detail: str = ""):
def add(self, label: str, passed: bool, detail: str = "", category: str | None = None):
# ORCH-061: every check carries a category. None -> auto-classify by label
# (C9a/C9b -> SANDBOX_INFRA, everything else -> REAL). Fail-closed: an
# unknown label is REAL, so it still counts toward the safety net.
if category is None:
category = _classify(label)
self._items.append((label, passed, detail))
self._categories.append(category)
line = _ok(label) if passed else _fail(label)
if detail:
line += f" [{detail}]"
print(line)
def categorized_items(self) -> list[tuple[str, bool, str]]:
"""Rows as ``(label, passed, category)`` for ``compute_staging_verdict``."""
return [
(label, passed, cat)
for (label, passed, _detail), cat in zip(self._items, self._categories)
]
def summary(self) -> bool:
passed = sum(1 for _, ok, _ in self._items if ok)
total = len(self._items)
all_ok = passed == total
colour = _GREEN if all_ok else _RED
# ORCH-061: per-category breakdown so an operator can tell a REAL failure
# (regression — fail-closed) from a SANDBOX_INFRA one (waivable).
rows = self.categorized_items()
real_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _REAL]
infra_fail = [lbl for lbl, ok, cat in rows if not ok and cat == _SANDBOX_INFRA]
print()
print(f"{_BOLD}{'='*60}{_RESET}")
print(f"{colour}{_BOLD} RESULT: {passed}/{total} checks PASS{_RESET}")
print(f" REAL failed : {real_fail or 'none'}")
print(f" SANDBOX_INFRA failed: {infra_fail or 'none'}")
print(f"{_BOLD}{'='*60}{_RESET}")
return all_ok
@@ -637,6 +701,28 @@ def _cleanup(plane_base, workspace, gitea_base, plane_headers, gitea_headers,
# Main
# ---------------------------------------------------------------------------
def _resolve_tolerance(cli_strict: bool) -> bool:
"""Resolve whether the infra-FAIL waiver is active (ORCH-061).
Precedence: an explicit ``--strict`` CLI flag forces it OFF (for honest manual
runs). Otherwise read ``settings.staging_infra_tolerance_enabled`` from the
running instance's own config (same pattern as B6's src.* import inside the
container). On ANY import/read error -> STRICT (False): we never waive when the
config is unreadable (fail-safe), and we say so.
"""
if cli_strict:
print(_info("tolerance: DISABLED via --strict (honest run)"))
return False
try:
from src.config import settings # noqa: WPS433 - lazy, mirrors B6
enabled = bool(settings.staging_infra_tolerance_enabled)
print(_info(f"tolerance: staging_infra_tolerance_enabled={enabled}"))
return enabled
except Exception as e:
print(_info(f"tolerance: config unavailable, defaulting to STRICT: {e}"))
return False
def main():
parser = argparse.ArgumentParser(
description="Live staging-stand check suite (ORCH-33)"
@@ -656,6 +742,15 @@ def main():
"full-real: also wait for the analyst agent (slow, costs credits)."
),
)
parser.add_argument(
"--strict",
action="store_true",
help=(
"ORCH-061: force strict suite — disable the sandbox-infra (C9a/C9b) "
"FAIL waiver even if staging_infra_tolerance_enabled=True. Use for an "
"honest 10/10 run once the sandbox bot accounts are provisioned."
),
)
args = parser.parse_args()
base = args.base_url.rstrip("/")
@@ -673,8 +768,23 @@ def main():
block_b(results)
block_c(base, results, args.mode)
all_ok = results.summary()
sys.exit(0 if all_ok else 1)
results.summary()
# ORCH-061: the EXIT CODE (which drives the deployer's staging_status verdict)
# comes from the infra-tolerant verdict, NOT a raw passed==total count. A run
# whose only failures are known sandbox-infra checks (C9a/C9b) is waived to
# exit 0 when tolerance is on; ANY real check failure still exits 1 (FR-4).
infra_tolerant = _resolve_tolerance(args.strict)
verdict = _verdict(results.categorized_items(), infra_tolerant)
if verdict.waived:
# FR-7 observability: make "green with an allowance" distinguishable from
# an honest green in the logs / captured deployer output.
print(f"{_YELLOW}{_BOLD}INFRA-WAIVED:{_RESET} "
f"{', '.join(verdict.waived)} "
f"(known sandbox-infra; real checks green)")
print(f"{_BOLD}VERDICT:{_RESET} {verdict.status} "
f"(exit {verdict.exit_code}) — {verdict.summary}")
sys.exit(verdict.exit_code)
if __name__ == "__main__":

View File

@@ -20,6 +20,33 @@ logger = logging.getLogger("orchestrator.launcher")
# never passed through to the CLI.
VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
# ORCH-061: action stages whose success is an ACTION (restart/retag), not a src
# edit — so "no changes to commit" is EXPECTED there, not under-delivery (FR-3).
_ACTION_STAGES = frozenset({"deploy-staging", "deploy"})
def action_stage_no_changes_note(stage, repo) -> str | None:
"""ORCH-061 (FR-3 / FR-7): observability for an empty diff on an action stage.
The ``deploy-staging`` / ``deploy`` stages are actions (restart / retag), not
code edits, so the post-run "no changes to commit" is the NORMAL case there —
advancement is decided by the agent exit-code + the staging/deploy gate verdict,
NEVER by the presence of a commit (FR-3 / AC-4). This is a PURE decision used
only to emit an explicit log line distinguishing an expected action-stage no-op
from a code-stage no-op; it has no effect on stage advancement.
Returns an explicit note string when the empty diff is expected (an action
stage of a self-deploy repo), else ``None``. Never raises.
"""
try:
if stage in _ACTION_STAGES:
from ..self_deploy import self_deploy_applies
if self_deploy_applies(repo):
return f"{stage}: no code changes (expected on action stage)"
return None
except Exception: # noqa: BLE001 - observability only, never raise
return None
def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix,
default_attr):
@@ -222,6 +249,11 @@ class AgentLauncher:
"""
if job.get("agent") == "deploy-finalizer":
return self._run_deploy_finalizer_job(job)
# ORCH-021: the reserved-agent `post-deploy-monitor` is also a
# DETERMINISTIC (no-LLM) tick — intercept it BEFORE _spawn and run one
# observation tick synchronously. Returns None (no agent_run row).
if job.get("agent") == "post-deploy-monitor":
return self._run_post_deploy_monitor_job(job)
return self._spawn(
job["agent"],
job["repo"],
@@ -251,6 +283,27 @@ class AgentLauncher:
pass
return None
def _run_post_deploy_monitor_job(self, job: dict):
"""ORCH-021: run one deterministic post-deploy monitor tick for a job.
Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row
done/failed here. The tick never-raises, but we guard anyway so a monitor
fault can never wedge the worker / starve other projects (AC-16).
"""
from ..db import mark_job
from .. import stage_engine
try:
stage_engine.run_post_deploy_monitor(job)
mark_job(job["id"], "done")
logger.info(f"post-deploy-monitor job {job['id']} done")
except Exception as e:
logger.error(f"post-deploy-monitor job {job['id']} failed: {e}")
try:
mark_job(job["id"], "failed", error=f"post-deploy-monitor error: {e}")
except Exception:
pass
return None
def _spawn(self, agent: str, repo: str, task_content: str = None,
task_id: int = None, job_id: int = None) -> int:
"""Shared spawn implementation for launch() and launch_job().
@@ -364,6 +417,14 @@ class AgentLauncher:
"UPDATE agent_runs SET output_path = ? WHERE id = ?",
(output_path, run_id),
)
# ORCH-065: stamp the agent process pid onto the job row so the job-reaper
# can probe liveness (os.kill(pid, 0)). proc.pid only exists after Popen,
# so this is a second UPDATE next to run_id/started_at (set above in _spawn).
if job_id is not None:
conn.execute(
"UPDATE jobs SET pid = ? WHERE id = ?",
(proc.pid, job_id),
)
conn.commit()
conn.close()
@@ -582,6 +643,22 @@ class AgentLauncher:
logger.warning(f"Agent run_id={run_id}: commit failed: {commit_result.stderr}")
else:
logger.info(f"Agent run_id={run_id}: no changes to commit")
# ORCH-061: on a self-deploy action stage (deploy-staging/deploy)
# an empty diff is EXPECTED (action, not a src edit). Emit an
# explicit observability line so an operator can tell this apart
# from a code-stage no-op. Does NOT affect advancement (decided by
# exit-code + gate verdict, never by a commit existing).
try:
_t = get_task_by_repo_branch(repo, branch)
_stage = _t["stage"] if _t else None
_note = action_stage_no_changes_note(_stage, repo)
if _note:
logger.info(f"Agent run_id={run_id}: {_note}")
except Exception as _e:
logger.debug(
f"Agent run_id={run_id}: action-stage no-changes note "
f"skipped: {_e}"
)
except Exception as e:
logger.error(f"Agent run_id={run_id}: post-run git failed: {e}")

View File

@@ -219,6 +219,22 @@ class Settings(BaseSettings):
image_freshness_enabled: bool = True
image_freshness_repos: str = ""
# ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite.
# The self-hosting deploy-staging stage looped because scripts/staging_check.py
# exited non-zero on ANY failed check, so two infra-only failures (sandbox bot
# accounts not members of the sandbox Plane project) produced staging_status:
# FAILED -> rollback deploy-staging -> development -> loop.
# True -> a run whose ONLY failures are allowlisted sandbox-infra checks
# (C9a/C9b) is waived to SUCCESS; ANY real pipeline check that fails
# still fails closed -> FAILED -> rollback (safety net intact, FR-4).
# False -> 1:1 pre-ORCH-061 strict behaviour: any FAIL -> FAILED -> rollback.
# Default True (mirrors merge_gate_enabled / image_freshness_enabled /
# self_deploy_enabled): the safety net holds regardless of the flag; the flag
# exists to instantly restore legacy strictness without a code redeploy. Lives
# in .env.staging (ORCH_ prefix) so it is reachable inside orchestrator-staging.
# Env ORCH_STAGING_INFRA_TOLERANCE_ENABLED.
staging_infra_tolerance_enabled: bool = True
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background
# daemon thread reconciles the "source of truth (gate / Plane) != task stage"
# drift left behind by a dropped webhook (502 on rebuild, no Plane/Gitea
@@ -234,12 +250,87 @@ class Settings(BaseSettings):
# JSON -> default (mirrors agent_timeout_overrides_json).
# reconcile_notify_unblock -> send a Telegram message when a stuck task is
# unblocked (F-4 observability).
# reconcile_skip_blocked_enabled -> ORCH-060 Guard 2: skip F-1 reconciliation of
# issues a human moved to Blocked / Needs Input
# (per-candidate Plane state lookup). Disabling it
# mutes ONLY the networked Guard 2; Guard 1
# (escalated-by-retries, local + deterministic) is
# always active. Manual escape hatch during a Plane
# outage.
reconcile_enabled: bool = True
reconcile_interval_s: int = 120
reconcile_plane_enabled: bool = True
reconcile_grace_default_s: int = 600
reconcile_grace_overrides_json: str = ""
reconcile_notify_unblock: bool = True
reconcile_skip_blocked_enabled: bool = True
# ORCH-021: post-deploy production monitoring + degradation reaction. After
# the terminal deploy->done transition for an applicable repo, a reserved-agent
# `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod
# over a window and reacts to a degradation the restart-time health-check
# missed (class "green deploy, red prod", precedent ET-8). State is in sentinel
# files (.post-deploy-state-<repo>/<wi>/), no DB migration. See
# docs/architecture/adr/adr-0010-post-deploy-monitor.md.
# post_deploy_monitor_enabled -> global kill-switch (BR-8); False -> the
# pipeline is 1:1 as before ORCH-021 (no arm).
# post_deploy_repos -> CSV of repos where monitoring is REAL; empty
# -> only the self-hosting repo (orchestrator).
# Mirrors self_deploy_repos / merge_gate_repos.
# post_deploy_window_s -> observation window length (~15 min, BR-1).
# post_deploy_interval_s -> seconds between probe ticks.
# post_deploy_fail_threshold -> N CONSECUTIVE health failures -> DEGRADED.
# post_deploy_5xx_threshold -> window 5xx ratio above this -> DEGRADED.
# post_deploy_auto_rollback -> globally allow auto-rollback; True acts ONLY
# for non-self repos. For self-hosting the
# reaction is ALWAYS ALERT_ONLY (BR-5) — a tick
# NEVER restarts the prod orchestrator container.
# post_deploy_base_url -> base URL of the observed prod instance.
# Rollback target params reuse the existing deploy_prod_* settings (no dupes).
post_deploy_monitor_enabled: bool = True
post_deploy_repos: str = ""
post_deploy_window_s: int = 900
post_deploy_interval_s: int = 30
post_deploy_fail_threshold: int = 3
post_deploy_5xx_threshold: float = 0.5
post_deploy_auto_rollback: bool = False
post_deploy_base_url: str = "http://localhost:8500"
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon
# thread (modelled on the reconciler) makes "the monitor thread / process died
# while a job/lease was held" self-heal WITHOUT a restart. Status (done/queued/
# failed) is otherwise only ever set by launcher._monitor_agent -> _finalize_job
# inside the live process; a death there left the jobs row 'running' forever and
# (at max_concurrency=1) wedged the queue of EVERY project (incidents 07.06: jobs
# 236/239/242/254). The same thread proactively reclaims a stale/dead merge-lease
# (ORCH-043) instead of waiting for the lazy TTL on the next foreign acquire. See
# docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md.
# reaper_enabled -> global kill-switch (false -> strictly prior behaviour;
# only the startup requeue_running_jobs remains).
# reaper_interval_s -> background scan period (seconds).
# reaper_dead_ticks -> Tier-1: consecutive ticks a job's pid must be dead
# before it is reaped (>=2 anti-false-positive; a live
# long-running agent is NEVER reaped).
# reaper_max_running_s -> Tier-3 backstop ceiling: a job 'running' longer than
# this is reaped even when liveness is unknowable. MUST be
# > max agent_timeout + grace so a legit agent is safe.
# reaper_finalize_grace_s -> Tier-2 anti-false-positive: a LIVE monitor writes
# agent_runs.exit_code FIRST, THEN does git commit/push +
# PR + Plane usage comments (seconds..minutes) and only
# then _finalize_job. The agent pid is already dead in
# that window, so pid cannot tell "monitor died" from
# "monitor still finalizing". A job is reaped via Tier-2
# only once exit_code has been recorded for at least this
# many seconds (MUST be > the max finalization window).
# lease_reclaim_enabled -> kill-switch for the proactive stale/dead lease reclaim
# (false -> only the legacy lazy TTL reclaim in acquire).
# (reuse) merge_lock_timeout_s -> lease TTL; merge_gate_repos -> reclaim scope.
reaper_enabled: bool = True
reaper_interval_s: int = 60
reaper_dead_ticks: int = 2
reaper_max_running_s: int = 3600
reaper_finalize_grace_s: int = 300
lease_reclaim_enabled: bool = True
# Telegram notifications
telegram_bot_token: str = ""

View File

@@ -76,6 +76,11 @@ def init_db():
# (CREATE TABLE IF NOT EXISTS won't add columns to an already-created table).
_ensure_column(conn, "jobs", "transient_attempts", "INTEGER NOT NULL DEFAULT 0")
_ensure_column(conn, "jobs", "available_at", "TEXT")
# ORCH-065: pid of the spawned agent process, stamped in launcher._spawn next to
# run_id/started_at. The job-reaper uses it for Tier-1 liveness (os.kill(pid, 0))
# to detect a 'running' job whose process died before _finalize_job. Idempotent
# ALTER (no-op once present) -> safe on the live prod DB.
_ensure_column(conn, "jobs", "pid", "INTEGER")
# ORCH-5 (M-7): webhook delivery de-dup. Add events.delivery_id and a PARTIAL
# unique index. Partial (WHERE delivery_id IS NOT NULL) so pre-existing rows
# (which have NULL delivery_id) never collide with each other. Restart-safe:
@@ -593,6 +598,82 @@ def requeue_running_jobs() -> int:
return int(n)
def get_running_jobs() -> list[dict]:
"""ORCH-065: snapshot of every 'running' job for the job-reaper scan.
Each row carries the job columns plus four reaper inputs:
* ``running_age_s`` — seconds since ``started_at`` (Tier-3 backstop);
* ``exit_code`` — the linked ``agent_runs.exit_code`` (Tier-2: process
finished but the job is still 'running' -> monitor died mid-finalize);
* ``finished_at_run`` — the linked ``agent_runs.finished_at``;
* ``finished_age_s`` — seconds since ``agent_runs.finished_at`` (Tier-2
finalization grace: a LIVE monitor writes exit_code, THEN does git
push / PR / Plane comments before _finalize_job, so a freshly-finished
run is NOT yet a zombie — the reaper waits ``reaper_finalize_grace_s``).
A LEFT JOIN on ``run_id`` keeps jobs with no agent_runs row (exit_code NULL).
Read-only; never mutates. The reaper applies liveness/streak/backstop on top.
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT j.*, "
"CAST(strftime('%s','now') - strftime('%s', j.started_at) AS INTEGER) "
" AS running_age_s, "
"r.exit_code AS exit_code, r.finished_at AS finished_at_run, "
"CAST(strftime('%s','now') - strftime('%s', r.finished_at) AS INTEGER) "
" AS finished_age_s "
"FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id "
"WHERE j.status='running'"
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def reap_running_job(
job_id: int,
status: str,
run_id: int | None = None,
error: str | None = None,
) -> bool:
"""ORCH-065: atomic terminal flip of a RUNNING job by the job-reaper.
Mirrors ``mark_job`` but carries the ``status='running'`` guard in the WHERE
clause and reports ``rowcount`` so a late-arriving monitor / the startup
``requeue_running_jobs`` / a second reaper tick can never double-process the
same row (AC-5, restart-safe). Returns True iff THIS call won the flip
(rowcount == 1); False -> someone else already moved the row.
Status semantics match ``mark_job``: done/failed stamp ``finished_at``; queued
clears ``started_at``/``finished_at`` so the next claim treats it as fresh.
"""
conn = get_db()
try:
sets = ["status = ?"]
params: list = [status]
if run_id is not None:
sets.append("run_id = ?")
params.append(run_id)
if error is not None:
sets.append("error = ?")
params.append(error)
if status in ("done", "failed"):
sets.append("finished_at = datetime('now')")
elif status == "queued":
sets.append("started_at = NULL")
sets.append("finished_at = NULL")
params.append(job_id)
cur = conn.execute(
f"UPDATE jobs SET {', '.join(sets)} WHERE id = ? AND status='running'",
params,
)
conn.commit()
return cur.rowcount == 1
finally:
conn.close()
def get_job(job_id: int) -> dict | None:
"""Fetch a single job by id."""
conn = get_db()

467
src/job_reaper.py Normal file
View File

@@ -0,0 +1,467 @@
"""ORCH-065: job-reaper + proactive merge-lease reclaim background daemon.
Three failure classes share one root cause — "the thread/process died while it
still held captured state" — and one inert recovery layer
(``requeue_running_jobs``) that only fires on a process restart:
* **A — zombie jobs.** A job's terminal status (``done``/``queued``/``failed``)
is written ONLY inside ``launcher._monitor_agent -> _finalize_job`` in the
live process. If that thread/process dies between ``proc.wait()`` and the
status write (crash, OOM, self-restart mid-deploy) the ``jobs`` row stays
``running`` forever. At ``max_concurrency=1`` one zombie blocks the claim of
EVERY project's jobs -> the whole shared pipeline stalls.
* **B — stuck merge-lease.** The file lease ``.merge-lease-<repo>.json``
(ORCH-043) is reclaimed only lazily, by TTL, and only when ANOTHER task tries
to acquire it. Holder liveness (pid) is never probed, so a death with the
lease held blocks foreign merges until the TTL expires.
This module is a background daemon thread modelled on ``reconciler``
(``threading.Thread(daemon=True)`` + ``threading.Event``, start/stop in
``main.lifespan``, ``/queue`` snapshot, per-unit never-raise, kill-switch). Each
tick: (1) scans ``running`` jobs and reaps the dead ones via three-tier liveness
detection; (2) proactively reclaims dead/stale merge-leases (mechanism B) for the
in-scope repos.
Liveness (defense in depth, ADR-001 Р-1):
* **Tier-1 (primary): dead pid.** ``jobs.pid`` (stamped by ``launcher._spawn``)
probed with ``merge_gate.pid_alive``. A job is reaped only after
``reaper_dead_ticks`` (>=2) CONSECUTIVE dead-pid ticks — an in-memory streak
counter kills false positives (AC-3); a live agent within its timeout is
never reaped.
* **Tier-2 (completion race): exit_code recorded but job still running.** This
window is AMBIGUOUS — it is both "the monitor died between writing
``agent_runs.exit_code`` and ``_finalize_job``" AND "a LIVE monitor is still
finalizing" (``_monitor_agent`` writes ``exit_code`` FIRST, then git
commit/push (+PR), the БАГ-8 check and network Plane usage comments — seconds
to tens of seconds — and ONLY THEN ``_try_advance_stage`` -> ``_finalize_job``).
The agent pid is already dead in BOTH cases, so it cannot disambiguate. The
reaper therefore treats it as a dead monitor (KNOWN outcome) only after a
finalization grace: ``exit_code`` recorded for >= ``reaper_finalize_grace_s``
(a live finalizing monitor is NEVER reaped, FR-1.3/AC-3). Within the grace the
row is left untouched.
* **Tier-3 (backstop): age ceiling.** A job ``running`` longer than
``reaper_max_running_s`` (deliberately > max ``agent_timeout`` + grace) is
reaped even when liveness cannot be determined (pid reused / unknown).
Action on confirmed death reuses existing contracts (no new merge/stage logic):
* The reaper's ONLY mutating write to a job row is the atomic terminal flip
``db.reap_running_job(... WHERE status='running')`` — so a late-arriving
monitor / the startup ``requeue_running_jobs`` / a second tick can never
double-process a row (AC-5; the loser sees ``rowcount==0``).
* **exit0 (Tier-2): claim-BEFORE-act (ADR-001 Р-1).** The source of truth is the
canonical quality gate, NOT "exit0". If the stage already advanced -> atomic
``done`` claim only (idempotent cleanup). Else evaluate the canonical QG
READ-ONLY (no side effects, the reconciler pattern): red (e.g. the monitor died
before git-push, so no artifact) -> failure path (no false ``done``); green ->
atomically claim ``done`` FIRST, and only the claim winner then runs
``launcher._try_advance_stage`` (advance + ``enqueue_job`` of the next stage).
A tick that loses the claim performs NO side effects, so a late-finalizing
monitor / the startup ``requeue_running_jobs`` can never be double-advanced or
double-enqueued.
* **exit!=0 (Tier-2) / unknown outcome (Tier-1 dead pid, Tier-3 backstop):**
``attempts < max_attempts`` -> ``queued`` (mirrors ``requeue_running_jobs``);
budget exhausted -> ``failed`` + Telegram. We never fabricate exit0.
Invariants (ТЗ §8 / ADR-001): never-raise per unit of work; idempotency (atomic
guard + gate-driven advance); restart-safe (the reaper starts AFTER the startup
``requeue_running_jobs``); silence when nothing is anomalous; the reaper NEVER
restarts/kills the prod container and NEVER pushes ``main``. ``STAGE_TRANSITIONS``
/ ``QG_CHECKS`` and every ``check_*`` signature are unchanged.
See docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md and
the cross-cutting docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md.
"""
import logging
import threading
from datetime import datetime, timezone
from .config import settings
from .db import (
get_db,
get_running_jobs,
reap_running_job,
)
from .stages import STAGE_TRANSITIONS, get_agent_for_stage
logger = logging.getLogger("orchestrator.job_reaper")
def reclaim_all_stale_leases() -> int:
"""Proactively reclaim dead/stale merge-leases for every in-scope repo.
Used both at startup (``main.lifespan``, next to ``requeue_running_jobs``) and
on every reaper tick (mechanism B). Iterates the merge-gate scope
(``merge_gate_repos`` CSV, else self-hosting ``orchestrator``) and calls the
never-raise ``merge_gate.reclaim_stale_lease`` per repo. Returns the number of
leases actually reclaimed. Never raises (per-repo isolation).
"""
if not settings.lease_reclaim_enabled:
return 0
reclaimed = 0
try:
from . import merge_gate
raw = (settings.merge_gate_repos or "").strip()
if raw:
repos = [r.strip() for r in raw.split(",") if r.strip()]
else:
from .qg.checks import SELF_HOSTING_REPO
repos = [SELF_HOSTING_REPO]
for repo in repos:
try:
if merge_gate.reclaim_stale_lease(repo):
reclaimed += 1
except Exception as e: # noqa: BLE001 - isolate one repo's failure
logger.error("lease-reclaim failed for repo %s: %s", repo, e)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.error("reclaim_all_stale_leases error: %s", e)
return reclaimed
class JobReaper:
"""Background daemon that reaps zombie jobs and reclaims stale merge-leases.
Modelled on ``Reconciler``: a ``threading.Thread(daemon=True)`` + a
``threading.Event`` for a clean stop. The only in-memory state is the
best-effort Tier-1 dead-pid streak counter (``_streak``) and the
observability counters (``reaped_total`` / ``last_reaped`` /
``lease_reclaimed_total`` / ``last_run_ts``); all reset on restart, which is
safe because the startup ``requeue_running_jobs`` covers the restart path.
"""
def __init__(self, interval_s: float | None = None):
self.interval_s = (
interval_s if interval_s is not None else settings.reaper_interval_s
)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# Tier-1 anti-false-positive: {job_id: consecutive dead-pid ticks}.
self._streak: dict[int, int] = {}
# Best-effort observability (Р-6).
self.last_run_ts: float | None = None
self.reaped_total: int = 0
self.last_reaped: dict | None = None
self.lease_reclaimed_total: int = 0
# -- A: zombie-job reaping --------------------------------------------
def reap_once(self) -> None:
"""One scan over all ``running`` jobs (per-job never-raise) + lease reclaim."""
if settings.reaper_enabled:
try:
running = get_running_jobs()
except Exception as e: # noqa: BLE001 - never break the tick
logger.error("reaper: get_running_jobs failed: %s", e)
running = []
seen: set[int] = set()
for job in running:
jid = job.get("id")
if jid is not None:
seen.add(jid)
try:
self._reap_job(job)
except Exception as e: # noqa: BLE001 - isolate one job's failure
logger.error(
"reaper: job %s (agent=%s) failed: %s",
job.get("id"), job.get("agent"), e,
)
# Forget streaks for rows that are no longer running (reaped / requeued
# / finished by the monitor) so the dict cannot grow unbounded.
self._streak = {k: v for k, v in self._streak.items() if k in seen}
# Mechanism B: proactive stale/dead lease reclaim (own kill-switch).
try:
self.lease_reclaimed_total += reclaim_all_stale_leases()
except Exception as e: # noqa: BLE001 - never break the tick
logger.error("reaper: lease reclaim sweep failed: %s", e)
def _reap_job(self, job: dict) -> None:
"""Apply the three-tier liveness policy to a single running job."""
from . import merge_gate
job_id = job["id"]
pid = job.get("pid")
age = int(job.get("running_age_s") or 0)
exit_code = job.get("exit_code") # from the LEFT JOIN on agent_runs
# Tier-2: the process finished (exit_code recorded) but the job is still
# 'running'. This is AMBIGUOUS: it is BOTH "the monitor died mid-finalize"
# AND "a LIVE monitor is still finalizing" — _monitor_agent writes exit_code
# FIRST, then does git commit/push (+PR), the БАГ-8 check, network Plane
# usage comments (seconds..tens of seconds), and ONLY THEN _try_advance_stage
# -> _finalize_job. The agent pid is already dead in BOTH cases, so pid can
# NOT disambiguate. We treat it as a dead monitor (KNOWN outcome) only after
# a finalization grace: exit_code must have been recorded for at least
# `reaper_finalize_grace_s` (FR-1.3/AC-3 — a live finalizing monitor is never
# reaped). Within the grace window we leave the row alone (and fall through to
# the Tier-3 backstop only, which never trips before the grace given a sane
# config where reaper_max_running_s > reaper_finalize_grace_s).
if exit_code is not None:
self._streak.pop(job_id, None)
finished_age = job.get("finished_age_s")
grace = int(settings.reaper_finalize_grace_s)
if finished_age is not None and int(finished_age) >= grace:
self._reap_known_outcome(job, int(exit_code))
return
logger.info(
"reaper: job %s exit_code=%s recorded %ss ago (< grace %ss) — "
"deferring (monitor may still be finalizing)",
job_id, exit_code, finished_age, grace,
)
# fall through to the Tier-3 backstop guard below.
else:
# Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks.
if pid is not None and not merge_gate.pid_alive(pid):
n = self._streak.get(job_id, 0) + 1
self._streak[job_id] = n
if n >= max(int(settings.reaper_dead_ticks), 1):
self._streak.pop(job_id, None)
self._reap_unknown_outcome(job, reason=f"dead pid={pid}")
return
logger.info(
"reaper: job %s pid=%s dead (streak %d/%d) — deferring",
job_id, pid, n, settings.reaper_dead_ticks,
)
else:
# Alive / no pid -> reset the streak (must be CONSECUTIVE).
self._streak.pop(job_id, None)
# Tier-3: backstop ceiling (one-shot; reaps even when liveness is unknown).
if age >= int(settings.reaper_max_running_s):
self._streak.pop(job_id, None)
self._reap_unknown_outcome(
job, reason=f"backstop age={age}s>={settings.reaper_max_running_s}s"
)
# -- reap actions ------------------------------------------------------
def _reap_known_outcome(self, job: dict, exit_code: int) -> None:
"""Tier-2: the agent's exit_code is known; drive the job's terminal status."""
if exit_code == 0:
self._reap_exit0(job)
else:
self._reap_unknown_outcome(job, reason=f"exit={exit_code}")
def _reap_exit0(self, job: dict) -> None:
"""Reap an exit0 Tier-2 job with claim-BEFORE-act (ADR-001 Р-1).
The atomic ``reap_running_job`` claim (guard ``WHERE status='running'``) MUST
precede any ``advance_stage`` / ``enqueue_job`` side effect, so a reaper tick
that LOSES the row (to a late-finalizing monitor or the startup
``requeue_running_jobs``) performs NO side effects — no duplicate advance, no
duplicate ``enqueue_job`` of the next stage (FR-1.2/AC-4).
Because the claim flips the row OUT of 'running', we cannot run the advance
first to learn the gate colour. Instead we evaluate the canonical quality gate
READ-ONLY (no side effects — the pattern the reconciler uses) to choose the
terminal status BEFORE claiming:
* already advanced past this agent -> idempotent clean ``done`` (no advance);
* gate green -> claim ``done`` first, THEN advance exactly once;
* gate red (e.g. monitor died before git-push -> no artifact) -> NOT a real
success: route to the retry/fail contract (never a false ``done``).
"""
job_id = job["id"]
run_id = job.get("run_id")
agent = job.get("agent")
branch, stage, work_item_id = self._task_meta(job)
candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent}
if stage is None or stage not in candidates:
# Stage already advanced past this agent (or unknown) -> a clean 'done'
# is correct WITHOUT re-advancing. Atomic claim only (idempotent cleanup).
if reap_running_job(job_id, "done", run_id=run_id):
self._note_reap(job, "done", reason="exit0, already advanced")
return
if not branch or not self._gate_is_green(stage, job, branch, work_item_id):
# exit0 but the gate is red -> do NOT fabricate 'done'; treat as failure
# (retry within budget, else failed + Telegram).
self._reap_unknown_outcome(job, reason="exit0 but gate red")
return
# Gate green. CLAIM-BEFORE-ACT: own the row atomically FIRST.
if not reap_running_job(job_id, "done", run_id=run_id):
# Lost the race -> the winner (late monitor / startup requeue) owns the
# advance; we do NOTHING (no duplicate side effects).
return
# We exclusively own the row now -> drive the gate-based advance exactly once.
self._gate_driven_advance(job)
self._note_reap(job, "done", reason="exit0, gate green")
def _gate_is_green(
self, stage: str, job: dict, branch: str, work_item_id: str | None
) -> bool:
"""Read-only canonical-QG evaluation for a reaped exit0 job (no side effects).
Mirrors the reconciler's cheap pre-evaluation: dispatch the stage's QG via
the SAME ``_run_qg`` the webhook path uses, returning its pass/fail WITHOUT
running ``advance_stage`` (so no stage move / enqueue / notification happens
here). A stage with no registered gate is treated as green (nothing blocks a
clean 'done'). Never raises -> any error returns False (conservative: route
to retry, never a false 'done').
"""
try:
from .stages import get_qg_for_stage
from .stage_engine import _run_qg
qg_name = get_qg_for_stage(stage)
if not qg_name:
return True
passed, _reason = _run_qg(qg_name, job.get("repo"), work_item_id, branch)
return bool(passed)
except Exception as e: # noqa: BLE001 - never break the reap
logger.warning(
"reaper: gate pre-eval failed for job %s (stage=%s): %s",
job.get("id"), stage, e,
)
return False
def _reap_unknown_outcome(self, job: dict, reason: str) -> None:
"""Tier-1/Tier-3 (or exit!=0): outcome not a clean success.
Mirrors ``requeue_running_jobs`` / the permanent-failure contract:
``attempts < max_attempts`` -> ``queued`` (a retry); budget exhausted ->
``failed`` + Telegram. The terminal flip is the atomic ``reap_running_job``
guard, so a racing requeue/monitor never double-processes the row.
"""
job_id = job["id"]
run_id = job.get("run_id")
attempts = int(job.get("attempts") or 0)
max_attempts = int(job.get("max_attempts") or 2)
err = f"reaped: {reason} (run_id={run_id})"
if attempts < max_attempts:
if reap_running_job(job_id, "queued", run_id=run_id, error=err):
self._note_reap(job, "queued", reason=reason)
else:
if reap_running_job(job_id, "failed", run_id=run_id, error=err):
self._note_reap(job, "failed", reason=reason)
self._notify_failed(job, reason)
def _gate_driven_advance(self, job: dict) -> bool:
"""Idempotent, gate-driven stage advance for a reaped exit0 job.
Returns True iff the stage is (or has become) advanced past this agent's
stage — i.e. the canonical quality gate is satisfied and a clean ``done``
is correct. Returns False when the gate is still red (the caller then
routes the job to the failure path instead of a false ``done``).
The advance itself reuses the UNCHANGED ``launcher._try_advance_stage``
(which runs the canonical QG and the unified ``advance_stage``); the
reaper never duplicates ``update_task_stage`` / ``enqueue_job``.
"""
agent = job.get("agent")
repo = job.get("repo")
run_id = job.get("run_id")
branch, stage, _wid = self._task_meta(job)
# Candidate stages whose finishing agent is THIS agent (deployer maps to
# both 'testing' and 'deploy-staging', hence a set).
candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent}
if stage is None or stage not in candidates:
# Stage already advanced past this agent (or unknown) -> idempotent
# cleanup: a clean 'done' is correct without re-advancing.
return True
if not branch:
return False
try:
from .agents.launcher import launcher
launcher._try_advance_stage(run_id, agent, repo, branch)
except Exception as e: # noqa: BLE001 - never break the reap
logger.error("reaper: gate-driven advance failed for job %s: %s",
job.get("id"), e)
return False
# Re-read the stage: advanced out of the candidate set -> gate was green.
_branch, new_stage, _wid2 = self._task_meta(job)
return new_stage is None or new_stage not in candidates
@staticmethod
def _task_meta(job: dict) -> tuple[str | None, str | None, str | None]:
"""Resolve (branch, stage, work_item_id) for the job's task. Never raises."""
task_id = job.get("task_id")
if not task_id:
return None, None, None
try:
conn = get_db()
row = conn.execute(
"SELECT branch, stage, work_item_id FROM tasks WHERE id = ?",
(task_id,),
).fetchone()
conn.close()
if not row:
return None, None, None
return row["branch"], row["stage"], row["work_item_id"]
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("reaper: task lookup failed for job %s: %s",
job.get("id"), e)
return None, None, None
def _notify_failed(self, job: dict, reason: str) -> None:
try:
from .notifications import send_telegram
send_telegram(
f"\U0001f6a8 reaper: job {job.get('id')} ({job.get('agent')}, "
f"repo {job.get('repo')}) reaped as FAILED: {reason}"
)
except Exception as e: # noqa: BLE001 - telegram best-effort
logger.warning("reaper: failed-notify telegram error: %s", e)
def _note_reap(self, job: dict, outcome: str, reason: str) -> None:
"""Record + log one successful reap (Р-6 observability)."""
self.reaped_total += 1
self.last_reaped = {
"job_id": job.get("id"),
"agent": job.get("agent"),
"outcome": outcome,
}
logger.warning(
"reaper: job %s (agent=%s, repo=%s, run_id=%s, pid=%s) reaped -> %s (%s)",
job.get("id"), job.get("agent"), job.get("repo"),
job.get("run_id"), job.get("pid"), outcome, reason,
)
# -- loop / lifecycle --------------------------------------------------
def _tick(self) -> None:
try:
self.reap_once()
finally:
self.last_run_ts = datetime.now(timezone.utc).timestamp()
def _run(self) -> None:
logger.info(
"JobReaper started (interval=%ss, enabled=%s, dead_ticks=%s, "
"max_running_s=%s, lease_reclaim=%s)",
self.interval_s, settings.reaper_enabled, settings.reaper_dead_ticks,
settings.reaper_max_running_s, settings.lease_reclaim_enabled,
)
while not self._stop.is_set():
try:
self._tick()
except Exception as e: # noqa: BLE001 - outer never-raise
logger.error("JobReaper loop error: %s", e)
self._stop.wait(self.interval_s)
logger.info("JobReaper stopped")
def start(self) -> None:
"""Start the daemon thread (idempotent: a live thread is a no-op)."""
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run, name="job-reaper", daemon=True
)
self._thread.start()
def stop(self, timeout: float = 5.0) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=timeout)
def status(self) -> dict:
"""Reaper snapshot for /queue observability (Р-6)."""
return {
"enabled": settings.reaper_enabled,
"interval": self.interval_s,
"last_run_ts": self.last_run_ts,
"reaped_total": self.reaped_total,
"last_reaped": self.last_reaped,
"lease_reclaimed_total": self.lease_reclaimed_total,
}
# Module-level singleton used by the FastAPI lifespan.
reaper = JobReaper()

View File

@@ -60,6 +60,19 @@ async def lifespan(app: FastAPI):
if requeued:
log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart")
# ORCH-065: proactive startup reclaim of dead/stale merge-leases, next to the
# queue-recovery above. A lease held by the previous (now dead) process pid is
# released at once instead of waiting for the TTL / a foreign acquire so the
# next merge is not blocked. Conditional (merge_gate_repos / self-hosting) and
# gated by ORCH_LEASE_RECLAIM_ENABLED; never raises.
try:
from .job_reaper import reclaim_all_stale_leases
reclaimed = reclaim_all_stale_leases()
if reclaimed:
log.warning(f"Startup lease-reclaim: reclaimed {reclaimed} stale merge-lease(s)")
except Exception as e:
log.warning(f"Startup lease-reclaim skipped: {e}")
# L-2: rotate old per-run logs at startup (best-effort; never fatal).
try:
import os as _os
@@ -85,13 +98,22 @@ async def lifespan(app: FastAPI):
from .reconciler import reconciler
reconciler.start()
# ORCH-065: start the job-reaper LAST (after requeue_running_jobs + the worker
# + the reconciler) so its atomic status='running' guard never races the
# startup requeue. It reaps zombie jobs and periodically reclaims stale
# merge-leases. Kill-switch: ORCH_REAPER_ENABLED.
from .job_reaper import reaper
reaper.start()
try:
yield
finally:
# Graceful shutdown order mirrors startup in reverse: stop the reconciler
# first (it must not enqueue new work while the worker is winding down),
# then the worker. Running agents keep going; their jobs are requeued on
# next start via queue-recovery if the process dies.
# Graceful shutdown order mirrors startup in reverse: stop the reaper
# first, then the reconciler (it must not enqueue new work while the
# worker is winding down), then the worker. Running agents keep going;
# their jobs are requeued on next start via queue-recovery if the
# process dies.
reaper.stop()
reconciler.stop()
worker.stop()
@@ -123,11 +145,15 @@ async def queue():
from .db import job_status_counts, recent_jobs
from .queue_worker import worker
from .reconciler import reconciler
from .job_reaper import reaper
from . import post_deploy
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
"poll_interval": worker.poll_interval,
"resilience": worker.status(),
"reconcile": reconciler.status(),
"reaper": reaper.status(),
"post_deploy": post_deploy.status(),
"recent": recent_jobs(10),
}

View File

@@ -338,3 +338,150 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None:
return
except OSError as e:
logger.warning("merge-lease release error for %s: %s", repo, e)
# ---------------------------------------------------------------------------
# ORCH-065: proactive stale/dead merge-lease reclaim (Problem B)
# ---------------------------------------------------------------------------
def pid_alive(pid) -> bool:
"""Return True iff process ``pid`` is alive (``os.kill(pid, 0)`` liveness probe).
Semantics (ADR-001 Р-2, never-raise):
* ``ProcessLookupError`` -> the process is gone -> ``False`` (reclaimable).
* ``PermissionError`` -> the pid exists but is owned by another user ->
``True`` (alive; conservatively do NOT reclaim).
* missing / invalid pid -> ``True`` (conservative: a lease that predates the
pid field, or a malformed pid, is NOT reclaimed on the liveness signal —
the TTL backstop still catches it).
Never raises; any unexpected OS/type error -> conservative ``True``.
"""
if not pid:
return True
try:
os.kill(int(pid), 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except (OSError, ValueError, TypeError):
return True
def _lease_reclaim_applies(repo: str) -> bool:
"""Whether proactive lease-reclaim is REAL for ``repo`` (same scope as merge-gate).
Reuses ``qg.checks._merge_gate_applies`` (``merge_gate_repos`` CSV, else the
self-hosting ``orchestrator``) so reclaim and the gate share one predicate
(ADR-001 Р-2 / FR-2.4). Imported lazily to avoid an import cycle (qg.checks
imports merge_gate lazily inside ``check_branch_mergeable``). Never raises:
any error -> ``False`` (no-op, the safe default).
"""
try:
from .qg.checks import _merge_gate_applies
return _merge_gate_applies(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("lease-reclaim applicability check failed for %s: %s", repo, e)
return False
def reclaim_stale_lease(repo: str) -> bool:
"""Proactively reclaim a dead/stale merge-lease for ``repo`` (ADR-001 Р-2).
Unlike the lazy TTL reclaim inside ``acquire_merge_lease`` (which only fires
when ANOTHER task tries to acquire), this releases the lease as soon as the
holder is provably gone — without waiting for the TTL or a foreign acquire:
* holder pid is dead (``pid_alive`` is False) -> reclaim, OR
* lease age >= ``merge_lock_timeout_s`` (TTL) -> reclaim (AC-7).
A LIVE holder within its TTL is never touched (AC-8 — protects a legitimate
in-flight merge). Reclaim is holder-aware (``release_merge_lease(repo,
branch=holder)``) so it can never delete a lease a different task acquired in
the meantime. Conditional (FR-2.4): real only for ``merge_gate_repos`` /
self-hosting; other repos -> no-op. Kill-switch ``lease_reclaim_enabled``.
Returns True iff a lease was reclaimed. Never raises (AC-9): any read/remove
error is logged and swallowed so a single bad lease never kills the reaper
thread. Does NOT run any git operation — only the lease file is removed.
"""
try:
if not settings.lease_reclaim_enabled:
return False
if not _lease_reclaim_applies(repo):
return False
path = _lease_path(repo)
existing = _read_lease(path)
if existing is None:
return False # no lease (or unreadable -> _read_lease already logged)
holder = existing.get("branch")
pid = existing.get("pid")
age = time.time() - float(existing.get("acquired_at") or 0)
dead = not pid_alive(pid)
expired = age >= settings.merge_lock_timeout_s
if not (dead or expired):
return False # live holder within TTL -> protect legitimate merge
why = f"dead pid={pid}" if dead else f"stale age={age:.0f}s>=TTL"
release_merge_lease(repo, branch=holder)
logger.warning(
"merge-lease for %s reclaimed proactively (%s, holder=%s)",
repo, why, holder,
)
try:
from .notifications import send_telegram
send_telegram(
f"\U0001f527 merge-lease для {repo} освобождён проактивно "
f"({why}, holder={holder})"
)
except Exception as e: # noqa: BLE001 - telegram best-effort, never fatal
logger.warning("lease-reclaim telegram failed for %s: %s", repo, e)
return True
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("reclaim_stale_lease unexpected error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# ORCH-065: idempotent merge finalization guard (Problem C)
# ---------------------------------------------------------------------------
def pr_already_merged(repo: str, branch: str) -> bool:
"""Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2).
A deterministic, read-only guard the merge path consults BEFORE attempting a
(second) merge so a re-driven / reaped task is idempotent: an already-merged
PR -> no-op, never a duplicate merge and never an error. This is the ONLY new
merge-related helper and it does NOT merge — it only READS the PR state via
the existing Gitea client, so it does not introduce duplicate merge logic.
Consultation point: the actual merge actor is the **deployer agent** (it merges
the feature PR at the start of the ``deploy`` stage — see webhooks/gitea.py),
so the wiring lives in the deployer prompt (``.openclaw/agents/deployer.md``),
which runs this exact function before any (re-)merge. The merge-gate quality
check (``qg.checks.check_branch_mergeable``) is intentionally NOT modified
(ORCH-065 AC-13: ``check_*`` behaviour unchanged) — it runs on the FIRST
deploy-staging -> deploy edge and does not re-run on a ``deploy``-stage re-drive,
which is exactly where the second-merge risk lives.
Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=<branch>`` and
reports True when any matching PR has ``merged == True``. Never raises (AC-9):
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
normal gate re-evaluate rather than silently skipping a real merge).
"""
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",
params={"state": "all", "head": branch},
headers=headers, timeout=_SHORT_TIMEOUT,
)
if resp.status_code != 200:
return False
for pr in resp.json() or []:
if pr.get("merged") is True:
return True
return False
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("pr_already_merged check failed for %s/%s: %s", repo, branch, e)
return False

View File

@@ -107,6 +107,19 @@ _DEFAULT_STATES = {
# Feature 2 (verdict statuses) — Approved / Rejected.
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
# ORCH-066 (meaningful Plane status model, layer B): six new logical keys.
# Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key
# (see _STATE_ALIAS_FALLBACK) so a project without these statuses created
# (enduro / Plane down / partial config) degrades to the current behaviour
# instead of producing an invalid PATCH state. The project-relative
# alias-fallback in get_project_states() overrides these with the *project's
# own* base UUID on the success path; these defaults are the last resort.
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
"analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
"code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review
"awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review
"deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress
"monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done
}
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
@@ -128,6 +141,29 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
"Needs Input": "needs_input",
"In Review": "in_review",
"Blocked": "blocked",
# ORCH-066: meaningful per-stage / human-input statuses (layer B).
"To Analyse": "to_analyse",
"Analysis": "analysis",
"Code-Review": "code_review",
"Awaiting Deploy": "awaiting_deploy",
"Deploying": "deploying",
"Monitoring after Deploy": "monitoring",
}
# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys.
# After resolving states by name from the Plane API, any NEW key the project did
# not define degrades to the UUID of its BASE key **from the same project** — so
# the indication falls back to the current status and the PATCH stays valid even
# for a partially-configured project. Enduro (none of the new statuses created)
# collapses every new key onto its base, i.e. strictly the pre-ORCH-066
# behaviour. Strengthened ORCH-059 AC-7 pattern.
_STATE_ALIAS_FALLBACK: dict[str, str] = {
"to_analyse": "in_progress",
"analysis": "in_progress",
"code_review": "review",
"awaiting_deploy": "in_review",
"deploying": "in_progress",
"monitoring": "done",
}
# Per-project state cache: {project_id: {logical_key: state_uuid}}
@@ -175,6 +211,16 @@ def get_project_states(project_id: str) -> dict[str, str]:
if not resolved:
raise ValueError("no recognisable states in API response")
# ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the
# project did not define, reuse the UUID of its BASE key FROM THIS SAME
# PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH
# state on a partially-configured orchestrator project). Runs BEFORE the
# _DEFAULT_STATES.setdefault below so a project's own base UUID wins over
# the static enduro default.
for new_key, base_key in _STATE_ALIAS_FALLBACK.items():
if new_key not in resolved and resolved.get(base_key):
resolved[new_key] = resolved[base_key]
# Fill any missing keys from _DEFAULT_STATES so callers always get a
# complete mapping (defensive against partial Plane configs).
for k, v in _DEFAULT_STATES.items():
@@ -210,14 +256,16 @@ def reload_project_states(project_id: str = None) -> None:
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
# when the pipeline ENTERS that stage. analysis stays driven by the existing
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
# in_progress until done. Needs Input / In Review / Blocked remain higher
# priority and are set explicitly elsewhere — do NOT override them from here.
# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and
# review -> Code-Review now have dedicated statuses. deploy keeps in_progress
# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked
# remain higher priority and are set explicitly elsewhere — do NOT override them
# from here.
STAGE_VISIBILITY_STATE = {
"analysis": "analysis", # ORCH-066: analysis stage -> Analysis status
"architecture": "architecture",
"development": "development",
"review": "review",
"review": "code_review", # ORCH-066: review stage -> Code-Review status
"testing": "testing",
}
@@ -225,22 +273,27 @@ STAGE_VISIBILITY_STATE = {
# update_issue_state now calls stage_to_state() instead of looking up here.
STAGE_TO_STATE = {
"created": _DEFAULT_STATES["todo"],
"analysis": _DEFAULT_STATES["in_progress"],
# ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias
# the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers /
# tests that compare against concrete UUIDs see byte-identical values.
"analysis": _DEFAULT_STATES["analysis"],
"architecture": _DEFAULT_STATES["architecture"],
"development": _DEFAULT_STATES["development"],
"review": _DEFAULT_STATES["review"],
"review": _DEFAULT_STATES["code_review"],
"testing": _DEFAULT_STATES["testing"],
"deploy": _DEFAULT_STATES["in_progress"],
"done": _DEFAULT_STATES["done"],
}
# Map orchestrator stage -> logical state key (project-independent).
# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review).
# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state).
_STAGE_TO_STATE_KEY = {
"created": "todo",
"analysis": "in_progress",
"analysis": "analysis",
"architecture": "architecture",
"development": "development",
"review": "review",
"review": "code_review",
"testing": "testing",
"deploy": "in_progress",
"done": "done",
@@ -278,6 +331,33 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
return None
def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
"""ORCH-060 (F-1 Guard 2): GET the Plane issue and return its current state uuid.
Used by the reconciler to honour an explicit human gate: an issue a person
moved to **Blocked** / **Needs Input** must not be auto-unblocked by the
sweeper. Reuses the exact GET issue-detail endpoint / shared token already
used by ``fetch_issue_sequence_id`` / ``fetch_issue_fields``.
Plane returns ``state`` as a bare uuid string; older shapes may nest it as a
``{"id": ...}`` dict — both are handled.
Returns None on network error, non-2xx, or a missing field — never raises, so
the caller can apply its conservative fallback (treat as "possibly blocked").
"""
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/"
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp.raise_for_status()
state = resp.json().get("state")
if isinstance(state, dict):
state = state.get("id")
return str(state) if state else None
except Exception as e:
logger.warning(f"fetch_issue_state failed for {issue_id}: {e}")
return None
import re as _re
@@ -548,6 +628,58 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None):
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_analysis(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Analysis' — analyst is working (start / resume).
Degrades to the project's In Progress UUID when the 'Analysis' status is not
created (alias-fallback). never-raise (via _set_issue_state_direct).
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["analysis"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_code_review(work_item_id: str, project_id: str = None):
"""ORCH-066: set issue to 'Code-Review' — review stage indication.
Degrades to the project's Review UUID when 'Code-Review' is not created.
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["code_review"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None):
"""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.
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["awaiting_deploy"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_deploying(work_item_id: str, project_id: str = None):
"""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.
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["deploying"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_monitoring(work_item_id: str, project_id: str = None):
"""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
created (so the board shows Done, exactly as before ORCH-066).
"""
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["monitoring"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
"""Feature 3: move the issue to the board status for a pipeline stage.

614
src/post_deploy.py Normal file
View File

@@ -0,0 +1,614 @@
"""Post-deploy production monitoring + degradation reaction (ORCH-021).
The pipeline used to end at ``deploy -> done`` and then **forget about prod**:
"success" meant the health-check passed at restart (~60s window in
``scripts/orchestrator-deploy-hook.sh``). The class of incidents "green deploy,
red prod" (precedent ET-8 — degradation appears minutes later under real
traffic; ``/health`` answers ``200 ok`` while the feature is broken) was never
caught. ORCH-021 extends responsibility **PAST** ``done``: after the terminal
transition for an applicable repo we arm an observation window
(``post_deploy_window_s`` ~15 min, interval ``post_deploy_interval_s``);
degradation is detected by deterministic thresholds and, when confirmed,
triggers a reaction.
The observation mechanism (ADR-001 §1, Variant B) is a **reserved-agent job**
``post-deploy-monitor`` — a deterministic, no-LLM job modelled exactly on
``deploy-finalizer``. One "tick" == one job: it does ONE probe, appends to a
persisted ``series`` file, classifies, and either re-queues itself with a delay
(``available_at_delay_s``) or finishes (DEGRADED -> reaction; or window expired
-> HEALTHY). Between ticks no job runs (it is scheduled in the future), so the
single worker stays free for other projects — exactly like the finalizer defer.
This module is a **leaf** (mirrors ``self_deploy.py`` / ``staging_verdict.py``):
it imports only config (and lazily ``qg.checks.is_self_hosting_repo``), never
``stage_engine`` / ``launcher`` — the orchestration that needs those lives in
``stage_engine.run_post_deploy_monitor``. Every public helper honours a
**never-raise** contract so a monitoring hiccup can never crash the worker /
lifespan / the pipeline of other projects (AC-16).
Restart-safe state lives in sentinel files under
``<repos_dir>/.post-deploy-state-<repo>/<work_item_id>/`` (mirrors the
deploy-state pattern, no DB migration — ТЗ §2.7):
* ``armed`` — monitoring armed for this work item (idempotency-guard, AC-15);
* ``series`` — JSON list of probe results (restart-safe streak/5xx counters);
* ``done`` — monitoring finished (anti-dupe, AC-15).
Self-hosting safety (BR-5 / AC-8): a monitor tick NEVER auto-rolls-back or
restarts the prod ``orchestrator`` container — for ``orchestrator`` the reaction
is ALWAYS ``ALERT_ONLY`` (loud Telegram + Plane, manual approve).
"""
from __future__ import annotations
import glob
import json
import logging
import os
import shlex
import subprocess
import urllib.error
import urllib.request
from dataclasses import dataclass
from .config import settings
logger = logging.getLogger("orchestrator.post_deploy")
# Sentinel marker filenames (see module docstring).
ARMED = "armed"
SERIES = "series"
DONE = "done"
# Verdicts (classify).
HEALTHY = "HEALTHY"
DEGRADED = "DEGRADED"
# Reaction decisions (decide_action).
NONE = "NONE"
ROLLBACK = "ROLLBACK"
ALERT_ONLY = "ALERT_ONLY"
# action_taken values written to the artefact frontmatter.
ROLLBACK_OK = "ROLLBACK_OK"
ROLLBACK_FAILED = "ROLLBACK_FAILED"
# The 5xx-monitored endpoints (besides /health, whose 200+ok is its own signal).
_FIVEXX_ENDPOINTS = ("/status", "/queue")
_PROBE_TIMEOUT = 5
_SSH_TIMEOUT = 60
_GIT_TIMEOUT = 60
# ---------------------------------------------------------------------------
# Conditionality (mirrors self_deploy_applies / _merge_gate_applies)
# ---------------------------------------------------------------------------
def post_deploy_applies(repo: str) -> bool:
"""Whether post-deploy monitoring is REAL for this repo (AC-2 / AC-10).
Mirrors the ORCH-35/36/43/58 conditional rollout:
* ``post_deploy_monitor_enabled=False`` -> always False (global
kill-switch); the pipeline is 1:1 as before ORCH-021 (AC-10).
* ``post_deploy_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
Never raises.
"""
try:
if not settings.post_deploy_monitor_enabled:
return False
raw = (settings.post_deploy_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (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 contract
logger.warning("post_deploy_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# Signal probe (one tick)
# ---------------------------------------------------------------------------
@dataclass
class ProbeResult:
"""Outcome of ONE probe tick (JSON-serialisable via ``as_dict``).
``health_ok`` — ``/health`` answered HTTP 200 with ``{"status": "ok"}``.
``total`` — number of 5xx-monitored endpoints probed (``/status``,
``/queue``) — the denominator of the window 5xx ratio.
``fivexx`` — how many of those returned 5xx (or were unreachable, which
is conservatively counted as a server failure).
``detail`` — human-readable note (logs / artefact body).
"""
health_ok: bool
total: int
fivexx: int
detail: str = ""
def as_dict(self) -> dict:
return {
"health_ok": bool(self.health_ok),
"total": int(self.total),
"fivexx": int(self.fivexx),
"detail": str(self.detail),
}
def _http_status(url: str) -> tuple[int, str]:
"""GET ``url`` -> (http_code, body). Network/timeout -> (0, "").
Never raises. ``urllib`` raises ``HTTPError`` for >=400 responses; we treat
that as a real status code (so a 5xx is observed, not swallowed).
"""
try:
with urllib.request.urlopen(url, timeout=_PROBE_TIMEOUT) as resp: # noqa: S310
body = resp.read(4096).decode("utf-8", "replace")
return int(getattr(resp, "status", resp.getcode())), body
except urllib.error.HTTPError as e:
try:
body = e.read(4096).decode("utf-8", "replace")
except Exception:
body = ""
return int(e.code), body
except Exception as e: # noqa: BLE001 - URLError / socket timeout / anything
logger.warning("post_deploy probe error for %s: %s", url, e)
return 0, ""
def probe_signals(base_url: str) -> ProbeResult:
"""Probe ``/health`` + the key endpoints of the prod instance ONCE (AC-16).
``/health`` is healthy iff HTTP 200 AND the body parses to
``{"status": "ok"}``. ``/status`` and ``/queue`` contribute to the window
5xx ratio: an HTTP 5xx OR an unreachable endpoint (network error / timeout,
code 0) is counted as a failure (conservative — a down server is bad). A
network failure yields a conservative "failed" probe, NEVER an exception
(TC-14).
"""
base = (base_url or "").rstrip("/")
# --- /health: the primary liveness signal ---
code, body = _http_status(base + "/health")
health_ok = False
if code == 200:
try:
health_ok = json.loads(body).get("status") == "ok"
except Exception:
health_ok = False
# --- /status, /queue: 5xx ratio over the window ---
total = 0
fivexx = 0
for ep in _FIVEXX_ENDPOINTS:
total += 1
ep_code, _ = _http_status(base + ep)
if ep_code == 0 or 500 <= ep_code <= 599:
fivexx += 1
detail = f"health={code}({'ok' if health_ok else 'bad'}) 5xx={fivexx}/{total}"
return ProbeResult(health_ok=health_ok, total=total, fivexx=fivexx, detail=detail)
# ---------------------------------------------------------------------------
# Classification (pure, no I/O — the MAIN unit-test subject, like
# compute_staging_verdict in ORCH-061)
# ---------------------------------------------------------------------------
def classify(series, fail_threshold: int, fivexx_threshold: float) -> str:
"""Fold a probe series into ``HEALTHY`` | ``DEGRADED`` (deterministic, pure).
``series`` — iterable of probe dicts (``{"health_ok", "total", "fivexx"}``),
as persisted by :func:`append_probe`.
Decision (BR-3 / AC-3..AC-6):
* ``>= fail_threshold`` CONSECUTIVE health failures -> ``DEGRADED`` (AC-4);
* window 5xx ratio ``sum(fivexx)/sum(total)`` strictly ``> fivexx_threshold``
-> ``DEGRADED`` even if ``/health`` answers 200 (AC-5);
* otherwise ``HEALTHY`` — a single glitch below the threshold that recovers
does NOT trip (AC-3 / AC-6, no false rollback).
Never raises: on malformed input it returns ``HEALTHY`` (fail-SAFE — a false
``DEGRADED`` would trigger an unwanted rollback, the worse outcome).
"""
try:
# Non-list input is malformed -> fail-safe HEALTHY (never a false rollback).
if not isinstance(series, (list, tuple)):
return HEALTHY
# Longest run of consecutive health failures.
streak = 0
best = 0
total = 0
fivexx = 0
for row in series:
# A non-dict row is malformed: skip it (do NOT count it as a failure,
# which could fabricate a DEGRADED streak from garbage).
if not isinstance(row, dict):
continue
ok = bool(row.get("health_ok"))
total += int(row.get("total") or 0)
fivexx += int(row.get("fivexx") or 0)
if ok:
streak = 0
else:
streak += 1
if streak > best:
best = streak
if best >= int(fail_threshold):
return DEGRADED
if total > 0 and (fivexx / total) > float(fivexx_threshold):
return DEGRADED
return HEALTHY
except Exception as e: # noqa: BLE001 - never-raise; fail-safe to HEALTHY
logger.warning("post_deploy classify error: %s", e)
return HEALTHY
def decide_action(repo: str, verdict: str) -> str:
"""Decide the reaction for ``(repo, verdict)`` (pure, BR-5 / AC-7 / AC-8).
* ``HEALTHY`` -> ``NONE`` (no reaction, any repo);
* ``DEGRADED`` + self-hosting -> ``ALERT_ONLY`` (ALWAYS — the tick
NEVER auto-rolls-back / restarts the prod orchestrator container, AC-8);
* ``DEGRADED`` + non-self + ``post_deploy_auto_rollback=True`` -> ``ROLLBACK``;
* ``DEGRADED`` + non-self + auto_rollback False (default) -> ``ALERT_ONLY``.
Never raises: on doubt returns ``ALERT_ONLY`` (never an unexpected rollback).
"""
try:
if verdict != DEGRADED:
return NONE
from .qg.checks import is_self_hosting_repo
if is_self_hosting_repo(repo):
return ALERT_ONLY # BR-5: self-hosting is NEVER auto-rolled-back
if settings.post_deploy_auto_rollback:
return ROLLBACK
return ALERT_ONLY
except Exception as e: # noqa: BLE001 - never-raise; safe default
logger.warning("post_deploy decide_action error for %s: %s", repo, e)
return ALERT_ONLY
def map_rollback_exit_code(exit_code) -> str:
"""Map a ``--rollback`` hook exit-code to an ``action_taken`` (pure, AC-9).
Hook exit-code contract (unchanged, 0/1/2):
* ``0`` -> ``ROLLBACK_OK`` (rollback proven healthy);
* ``1`` (no prev image), ``2`` (rollback also failed), anything else, or a
non-int/None -> ``ROLLBACK_FAILED`` (fail-closed -> loud escalation).
"""
try:
code = int(exit_code)
except (TypeError, ValueError):
return ROLLBACK_FAILED
return ROLLBACK_OK if code == 0 else ROLLBACK_FAILED
# ---------------------------------------------------------------------------
# Sentinel state (restart-safe, no DB migration — ТЗ §2.7)
# ---------------------------------------------------------------------------
def _state_dir(base: str, repo: str, work_item_id: str | None) -> str:
return os.path.join(base, f".post-deploy-state-{repo}", (work_item_id or "_"))
def state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen from the container (``settings.repos_dir`` mount)."""
return _state_dir(settings.repos_dir, repo, work_item_id)
def host_state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen from the HOST (``settings.host_repos_dir``).
Same physical directory as :func:`state_dir` via the shared mount; the host
path is what we embed in an ssh command if a host-side helper needs it.
"""
return _state_dir(settings.host_repos_dir, repo, work_item_id)
def marker_path(repo: str, work_item_id: str | None, name: str) -> str:
return os.path.join(state_dir(repo, work_item_id), name)
def has_marker(repo: str, work_item_id: str | None, name: str) -> bool:
"""True iff the named sentinel exists. Never raises."""
try:
return os.path.isfile(marker_path(repo, work_item_id, name))
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("has_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
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."""
try:
d = state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, name), "w", encoding="utf-8") as f:
f.write(str(content))
return True
except OSError as e:
logger.warning("write_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def mark_done(repo: str, work_item_id: str | None) -> bool:
"""Mark monitoring finished for this work item (anti-dupe, AC-15)."""
return write_marker(repo, work_item_id, DONE, "done")
def read_series(repo: str, work_item_id: str | None) -> list:
"""Read the persisted probe series (JSON list). Missing/corrupt -> ``[]``.
Never raises — restart-safe streak/5xx counters survive a container restart.
"""
p = marker_path(repo, work_item_id, SERIES)
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except FileNotFoundError:
return []
except Exception as e: # noqa: BLE001 - never-raise; corrupt -> empty
logger.warning("read_series error for %s/%s: %s", repo, work_item_id, e)
return []
def append_probe(repo: str, work_item_id: str | None, probe: ProbeResult) -> list:
"""Append a probe to the persisted series and return the new list.
Best-effort (a write error logs and returns the in-memory list so the tick
still classifies). Never raises.
"""
series = read_series(repo, work_item_id)
try:
series.append(probe.as_dict() if isinstance(probe, ProbeResult) else dict(probe))
except Exception as e: # noqa: BLE001
logger.warning("append_probe coerce error for %s/%s: %s", repo, work_item_id, e)
return series
try:
d = state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f:
json.dump(series, f)
except OSError as e:
logger.warning("append_probe write error for %s/%s: %s", repo, work_item_id, e)
return series
def arm_monitor(repo: str, work_item_id: str | None, branch: str, task_id: int) -> bool:
"""Arm post-deploy monitoring after ``deploy -> done`` (AC-1 / AC-15).
Idempotent: if the ``armed`` sentinel already exists this is a no-op (a double
webhook / reconciler F-1 / finalizer Phase C can drive ``done`` more than once,
AC-15). Otherwise creates the state dir, writes ``armed`` + an empty ``series``,
and enqueues the FIRST ``post-deploy-monitor`` job with a delay of one interval
(so the prod has settled before the first probe). Returns True iff it armed a
NEW monitor. Never raises — the caller (terminal block of ``advance_stage``)
must never be crashed by a monitoring hiccup.
"""
try:
if has_marker(repo, work_item_id, ARMED):
logger.info("arm_monitor: already armed for %s/%s (no-op)", repo, work_item_id)
return False
write_marker(repo, work_item_id, ARMED, "armed")
# Initialise an empty series so read_series is well-defined from tick 1.
try:
d = state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f:
json.dump([], f)
except OSError as e:
logger.warning("arm_monitor: series init error for %s/%s: %s", repo, work_item_id, e)
# Lazy import keeps this module a leaf (db is a low-level dependency).
from .db import enqueue_job
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: post-deploy\nNote: post-deploy monitor tick 1 "
f"(window {settings.post_deploy_window_s}s, interval "
f"{settings.post_deploy_interval_s}s)."
)
job_id = enqueue_job(
"post-deploy-monitor", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.post_deploy_interval_s,
)
logger.info(
"arm_monitor: armed post-deploy monitor for %s/%s (job_id=%s)",
repo, work_item_id, job_id,
)
return True
except Exception as e: # noqa: BLE001 - never-raise contract
logger.error("arm_monitor error for %s/%s: %s", repo, work_item_id, e)
return False
def max_ticks() -> int:
"""Bounded tick budget for the window (anti-livelock, like
``deploy_finalize_max_attempts``): ``window_s // interval_s`` (>= 1)."""
try:
interval = max(1, int(settings.post_deploy_interval_s))
return max(1, int(settings.post_deploy_window_s) // interval)
except Exception: # noqa: BLE001 - never-raise
return 1
# ---------------------------------------------------------------------------
# Rollback command (non-self repos only; reuses deploy_prod_* env — ТЗ §2.4)
# ---------------------------------------------------------------------------
def build_rollback_command(repo: str) -> list[str]:
"""Build the ssh argv that runs the deploy hook in ``--rollback`` mode.
Mirrors ``self_deploy.build_deploy_command`` (same prod-env, INFRA P-2 ssh
target) but the action is ``--rollback`` and the call is SYNCHRONOUS (the
target container is NOT the orchestrator, so it is safe to wait for the hook
exit-code directly — no detached setsid wrapper, no ``result`` sentinel).
Reuses the existing ``deploy_prod_*`` settings; no new duplicate config.
"""
env_assignments = (
f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} "
f"TARGET_PORT={int(settings.deploy_prod_target_port)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_target_image)} "
f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} "
f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}"
)
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "
f"bash {shlex.quote(settings.deploy_hook_script)} --rollback"
)
user = (settings.deploy_ssh_user or "").strip()
host = (settings.deploy_ssh_host or "").strip()
target = f"{user}@{host}" if user else host
return ["ssh", "-o", "StrictHostKeyChecking=no", target, inner]
def run_rollback(repo: str) -> tuple[int, str]:
"""Run the ``--rollback`` hook synchronously. Returns ``(exit_code, detail)``.
Never raises: an ssh launch error / timeout maps to a non-zero exit-code so
the caller records ``ROLLBACK_FAILED`` and escalates (AC-9). NEVER used for
the self-hosting repo (``decide_action`` returns ``ALERT_ONLY`` there) — the
structural guard against a tick restarting the prod orchestrator (AC-8).
"""
cmd = build_rollback_command(repo)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_SSH_TIMEOUT)
except subprocess.TimeoutExpired:
return 2, "rollback ssh timeout"
except (subprocess.SubprocessError, OSError) as e:
return 2, f"rollback ssh error: {e}"
detail = ((r.stderr or "") + (r.stdout or "")).strip()[:200]
return int(r.returncode), detail
# ---------------------------------------------------------------------------
# Artefact 16-post-deploy-log.md (machine-readable frontmatter — ТЗ §2.5)
# ---------------------------------------------------------------------------
def build_post_deploy_log(
work_item_id: str,
status: str,
action_taken: str,
window_s: int,
checks_total: int,
checks_failed: int,
body_extra: str = "",
) -> str:
"""Render a 16-post-deploy-log.md body. Only the YAML-frontmatter is machine
read (canon of gates; the loop-of-lessons ORCH-8 consumes it, BR-10). The
body is informational. Parseable by ``yaml.safe_load`` (AC-13).
"""
return (
"---\n"
f"post_deploy_status: {status}\n"
f"action_taken: {action_taken}\n"
f"work_item: {work_item_id}\n"
f"window_s: {int(window_s)}\n"
f"checks_total: {int(checks_total)}\n"
f"checks_failed: {int(checks_failed)}\n"
"---\n\n"
"# Post-deploy log — ORCH-021 post-deploy monitor\n\n"
f"Наблюдение прода завершено: `post_deploy_status: {status}`, "
f"`action_taken: {action_taken}`.\n\n"
f"Окно наблюдения: {int(window_s)}s; опросов всего: {int(checks_total)}, "
f"из них с провалом: {int(checks_failed)}.\n"
f"{body_extra}"
)
def write_post_deploy_log(
repo: str,
work_item_id: str,
branch: str,
status: str,
action_taken: str,
window_s: int,
checks_total: int,
checks_failed: int,
body_extra: str = "",
) -> bool:
"""Write 16-post-deploy-log.md into the task worktree and best-effort
commit+push it. Returns True iff the file was written. Never raises — the
artefact is best-effort, its absence rolls nothing back (AC-13 / TC-15).
"""
from .git_worktree import get_worktree_path
rel = f"docs/work-items/{work_item_id}/16-post-deploy-log.md"
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.error("write_post_deploy_log: worktree error for %s/%s: %s", repo, branch, e)
return False
path = os.path.join(wt, rel)
content = build_post_deploy_log(
work_item_id, status, action_taken, window_s, checks_total, checks_failed, body_extra
)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
logger.error("write_post_deploy_log: write error at %s: %s", path, e)
return False
git_env = {
**os.environ,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "post-deploy-monitor",
"GIT_AUTHOR_EMAIL": "post-deploy-monitor@mva154.local",
"GIT_COMMITTER_NAME": "post-deploy-monitor",
"GIT_COMMITTER_EMAIL": "post-deploy-monitor@mva154.local",
}
try:
subprocess.run(["git", "-C", wt, "add", rel],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
commit = subprocess.run(
["git", "-C", wt, "commit", "-m",
f"docs(ORCH-021): post-deploy {status}/{action_taken} for {work_item_id}"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env,
)
if commit.returncode == 0:
subprocess.run(["git", "-C", wt, "push", "origin", branch],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("write_post_deploy_log: git commit/push best-effort failed: %s", e)
return True
# ---------------------------------------------------------------------------
# Observability snapshot for GET /queue (BR-9 / AC-14)
# ---------------------------------------------------------------------------
def status() -> dict:
"""Post-deploy snapshot for /queue observability. Never raises.
``active`` — work items with an ``armed`` sentinel but no ``done`` yet (a
monitoring window in flight). ``last_outcome`` — best-effort last finished
window read from the most-recent ``done`` state dir's series length.
"""
snap = {
"enabled": False,
"window_s": None,
"interval_s": None,
"repos": "",
"active": [],
"active_count": 0,
}
try:
snap["enabled"] = bool(settings.post_deploy_monitor_enabled)
snap["window_s"] = int(settings.post_deploy_window_s)
snap["interval_s"] = int(settings.post_deploy_interval_s)
snap["repos"] = settings.post_deploy_repos or ""
pattern = os.path.join(settings.repos_dir, ".post-deploy-state-*", "*")
active: list[str] = []
for d in glob.glob(pattern):
try:
if not os.path.isdir(d):
continue
if os.path.isfile(os.path.join(d, ARMED)) and not os.path.isfile(
os.path.join(d, DONE)
):
active.append(os.path.basename(d))
except Exception: # noqa: BLE001 - skip one dir
continue
snap["active"] = sorted(active)
snap["active_count"] = len(active)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("post_deploy status snapshot error: %s", e)
return snap

View File

@@ -19,7 +19,12 @@ handlers a webhook would use:
canonical quality gate; green -> advance through the unchanged
``stage_engine.advance_stage(..., finished_agent=None)``; red -> silence
(no advance, no notification). ``analysis`` is NOT reconciled here (human
gate; owned by F-2).
gate; owned by F-2). **ORCH-060:** before the gate is even evaluated, F-1
skips (silently) tasks that are waiting for a human — Guard 1: escalated by
developer retries (``developer_retry_count >= MAX_DEVELOPER_RETRIES``,
deterministic, local; closes the ET-013 bounce loop) checked first, then
Guard 2: an explicit Plane ``Blocked`` / ``Needs Input`` state (Variant A —
networked, never-raise -> conservative skip).
* **F-2 plane-side** (``reconcile_plane_once``): poll the Plane API per
project (``list_issues_by_state``) and replay In Progress / Approved /
@@ -49,9 +54,13 @@ from .db import (
get_task_by_plane_id,
has_active_job_for_task,
)
from .stage_engine import advance_if_gate_passed
from .stage_engine import (
advance_if_gate_passed,
developer_retry_count,
MAX_DEVELOPER_RETRIES,
)
from .stages import get_qg_for_stage
from .plane_sync import get_project_states, list_issues_by_state
from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state
from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram
from . import projects
@@ -162,6 +171,17 @@ class Reconciler:
age_s = task.get("age_s") or 0
if age_s < grace_for_stage(stage):
return
# ORCH-060 Guard 1: escalated tasks (developer retries reached the cap) are
# terminal — they wait for a human, not the sweeper. Without this, a task
# whose CI is green but whose reviewer kept sending REQUEST_CHANGES until the
# cap would be re-unblocked every tick (incident ET-013, infinite bounce).
# Deterministic, local SQL, no network — and checked FIRST (cheapest).
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
return
# ORCH-060 Guard 2: respect an explicit human gate (Blocked / Needs Input).
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
if self._is_blocked_or_needs_input(task):
return
result = advance_if_gate_passed(
task_id,
stage,
@@ -172,6 +192,66 @@ class Reconciler:
if result is not None and getattr(result, "advanced", False):
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
def _is_blocked_or_needs_input(self, task: dict) -> bool:
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
an active orchestrator wait that F-1 must not "revive"?
Variant A (no schema migration): resolve the task's Plane project, fetch
the issue's current state uuid and compare against a skip-set. ``tasks``
has no status column, so the live Plane state is the source of truth.
Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the
ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``,
BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto
``in_review`` / ``in_progress`` / ``done`` on a project that did not create
them. Adding them verbatim would make F-1 wrongly skip enduro
In Progress / Done tasks (regression of ORCH-053/060). So they are
included ONLY when DISTINCT from the project's base working statuses
(i.e. actually created as separate statuses): enduro collapses them to {}
-> zero regress; orchestrator keeps three real statuses -> BR-13.
**Never-raise, conservative fallback.** Any error / unresolved project /
missing state -> return ``True`` (treat as "possibly blocked" -> skip):
NOT unblocking a task is always safe, whereas wrongly unblocking a
human-gated task re-introduces the bounce we are trying to kill. The
sub-flag ``reconcile_skip_blocked_enabled`` disables ONLY this networked
guard (escape hatch for a Plane outage); Guard 1 stays active.
"""
if not settings.reconcile_skip_blocked_enabled:
return False
try:
proj = projects.get_project_by_repo(task.get("repo") or "")
if proj is None:
return True # cannot resolve the project -> conservative skip
pid = proj.plane_project_id
states = get_project_states(pid)
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
cur = fetch_issue_state(issue_id, pid)
if cur is None:
return True # Plane unreachable / no state -> conservative skip
# ORCH-066 BR-13: active orchestrator waits, minus base working
# statuses so aliased (enduro) keys never widen the skip-set.
base_working = {
states.get(k) for k in (
"backlog", "todo", "in_progress", "in_review", "review",
"architecture", "development", "testing",
"approved", "rejected", "done",
)
}
extra_waits = {
states.get("awaiting_deploy"),
states.get("deploying"),
states.get("monitoring"),
} - base_working - {None}
skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits
return cur in skip_set
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(
f"reconciler Guard 2: blocked-check failed for task "
f"{task.get('id')}, skipping conservatively: {e}"
)
return True
# -- F-2: plane-side ---------------------------------------------------
def reconcile_plane_once(self) -> None:
"""One F-2 pass: poll Plane per project and replay missed transitions."""
@@ -186,15 +266,19 @@ class Reconciler:
def _reconcile_plane_project(self, proj) -> None:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
# ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was
# In Progress). On a project without that status, `to_analyse` aliases to
# the project's own `in_progress` UUID, so enduro behaviour is identical
# (and `list_issues_by_state` deduplicates the uuid via its internal set).
states = get_project_states(pid)
in_progress = states["in_progress"]
to_analyse = states["to_analyse"]
approved = states["approved"]
rejected = states["rejected"]
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
issues = list_issues_by_state(pid, [to_analyse, approved, rejected])
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected
issue, pid, to_analyse, approved, rejected
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
@@ -203,7 +287,7 @@ class Reconciler:
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
to_analyse: str, approved: str, rejected: str,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
@@ -233,10 +317,16 @@ class Reconciler:
"description_stripped": issue.get("description_stripped", ""),
}
if new_state == in_progress and task is None:
# In Progress without a task -> start the pipeline (lost start webhook).
if new_state == to_analyse and task is None:
# To Analyse without a task -> start the pipeline (lost start webhook).
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(issue_id, "analysis")
elif new_state == to_analyse and task is not None:
# To Analyse with an existing (idle) task -> resume the analyst from
# Needs Input (lost resume webhook). handle_status_start applies its
# own busy-guard / start-vs-resume fork.
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
elif new_state == approved and task is not None:
# Approved but the stage never advanced -> replay the verdict.
self._dispatch(handle_verdict, issue_data, project_id, approved=True)

View File

@@ -37,6 +37,7 @@ from .review_parse import extract_review_findings, extract_test_failures
from .qg.checks import QG_CHECKS
from . import merge_gate
from . import self_deploy
from . import post_deploy
from .notifications import (
notify_stage_change,
notify_qg_failure,
@@ -52,6 +53,10 @@ from .plane_sync import (
set_issue_in_progress,
set_issue_blocked,
set_issue_done,
set_issue_analysis,
set_issue_awaiting_deploy,
set_issue_deploying,
set_issue_monitoring,
)
from .config import settings
@@ -142,8 +147,14 @@ def _check_review_approved_by_branch(check_fn, repo: str, work_item_id: str, bra
return False, f"Error finding PR: {e}"
def _developer_retry_count(task_id: int) -> int:
"""How many developer runs have already happened for this task."""
def developer_retry_count(task_id: int) -> int:
"""How many developer runs have already happened for this task.
Single source of truth for the developer-retry count: the rollback path
(REQUEST_CHANGES / test-fail / merge-gate) and the ORCH-060 reconciler guard
both read the cap from here, so the SQL is never duplicated. ``task`` is
considered *escalated* once this reaches ``MAX_DEVELOPER_RETRIES``.
"""
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'",
@@ -153,6 +164,10 @@ def _developer_retry_count(task_id: int) -> int:
return n
# Backward-compat private alias — existing internal call sites keep working.
_developer_retry_count = developer_retry_count
def advance_stage(
task_id: int,
current_stage: str,
@@ -324,14 +339,28 @@ def advance_stage(
# here, so explicitly drive the Plane issue into the terminal Done state
# (PLANE_STATES['done'] — mapping unchanged) in addition to the
# stage-change comment above.
# ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy
# monitoring applies. For self-hosting (post_deploy_applies==True) the
# task enters a `Monitoring after Deploy` window, NOT terminal Done yet —
# the monitor finalises Done/Blocked (run_post_deploy_monitor). For
# non-self repos the behaviour is unchanged: terminal Done immediately.
# Where the `Monitoring after Deploy` status is absent, set_issue_monitoring
# degrades to the project's Done UUID -> identical to today.
if next_stage == "done" and work_item_id:
try:
set_issue_done(work_item_id)
logger.info(
f"Task {task_id}: deploy->done, Plane state forced to Done"
)
if post_deploy.post_deploy_applies(repo):
set_issue_monitoring(work_item_id)
logger.info(
f"Task {task_id}: deploy->done (self), Plane state -> "
f"Monitoring after Deploy (post-deploy window)"
)
else:
set_issue_done(work_item_id)
logger.info(
f"Task {task_id}: deploy->done, Plane state forced to Done"
)
except Exception as e:
logger.error(f"Task {task_id}: failed to set Plane Done: {e}")
logger.error(f"Task {task_id}: failed to set Plane terminal state: {e}")
# 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
@@ -342,6 +371,17 @@ def advance_stage(
except Exception as e: # noqa: BLE001 - defensive
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) -----
next_agent = get_agent_for_stage(current_stage)
if next_agent:
@@ -644,7 +684,9 @@ def _handle_qg_failure_rollbacks(
notify_stage_change(task_id, current_stage, "analysis")
plane_notify_stage(work_item_id, current_stage, "analysis")
result.rolled_back_to = "analysis"
set_issue_in_progress(work_item_id)
# ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis`
# (degrades to In Progress where the status is not created).
set_issue_analysis(work_item_id)
with open(conflict_path, "r") as cf:
conflict_text = cf.read()[:500]
plane_add_comment(
@@ -987,7 +1029,11 @@ def _handle_self_deploy_phase_a(
result.note = "self-deploy-approval-pending"
if work_item_id:
set_issue_in_review(work_item_id)
# 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
# stays for analyst BRD/review approve-pending only). Degrades to In Review
# where the status is not created.
set_issue_awaiting_deploy(work_item_id)
# 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
# here too guarantees the entry to every new prod-deploy pass starts clean
@@ -1047,6 +1093,10 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv
self_deploy.write_marker(
repo, work_item_id, self_deploy.INITIATED, content=str(time.time())
)
# ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying`
# (degrades to In Progress where the status is not created).
if work_item_id:
set_issue_deploying(work_item_id)
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)."
@@ -1166,3 +1216,154 @@ def run_deploy_finalizer(job: dict):
branch=branch,
finished_agent="deployer",
)
def run_post_deploy_monitor(job: dict):
"""ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM).
A deterministic tick modelled on ``run_deploy_finalizer``: it does ONE probe
of the prod instance, appends to the persisted ``series`` (restart-safe
streak/5xx counters), classifies, and then either RE-QUEUES itself with a
delay (window not over and still HEALTHY) or FINISHES the window (DEGRADED ->
reaction; window expired -> HEALTHY). Observation happens entirely AFTER the
terminal ``done`` — it never touches ``STAGE_TRANSITIONS`` / ``QG_CHECKS`` and
never restarts the prod orchestrator container itself (AC-8 / AC-12).
never-raise into the caller (the launcher marks the job done/failed); each
branch is individually defensive.
"""
task_id = job.get("task_id")
repo = job.get("repo")
try:
conn = get_db()
row = conn.execute(
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
except Exception as e: # noqa: BLE001 - never-raise
logger.error(f"post-deploy-monitor: db error for task_id={task_id}: {e}")
return
if not row:
logger.error(f"post-deploy-monitor: no task row for task_id={task_id}")
return
work_item_id, branch = row[0], row[1]
# 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):
logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)")
return
# One probe -> append -> classify (restart-safe via the persisted series).
probe = post_deploy.probe_signals(settings.post_deploy_base_url)
series = post_deploy.append_probe(repo, work_item_id, probe)
verdict = post_deploy.classify(
series,
settings.post_deploy_fail_threshold,
settings.post_deploy_5xx_threshold,
)
ticks = len(series)
budget = post_deploy.max_ticks()
logger.info(
f"post-deploy-monitor: {work_item_id} tick {ticks}/{budget} "
f"probe=[{probe.detail}] verdict={verdict}"
)
# HEALTHY and window not exhausted -> defer the next tick (worker stays free).
if verdict == post_deploy.HEALTHY and ticks < budget:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: post-deploy\nNote: post-deploy monitor tick {ticks + 1} "
f"(healthy so far; re-poll after {settings.post_deploy_interval_s}s)."
)
enqueue_job(
"post-deploy-monitor", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.post_deploy_interval_s,
)
return
checks_total = ticks
checks_failed = sum(1 for r in series if not r.get("health_ok"))
# HEALTHY and window exhausted -> clean finish (BR-6 / AC-17).
if verdict == post_deploy.HEALTHY:
post_deploy.write_post_deploy_log(
repo, work_item_id, branch, post_deploy.HEALTHY, post_deploy.NONE,
settings.post_deploy_window_s, checks_total, checks_failed,
)
post_deploy.mark_done(repo, work_item_id)
# ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done.
if work_item_id:
try:
set_issue_done(work_item_id)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}")
_notify_post_deploy(
work_item_id,
f"{work_item_id}: пост-деплой окно завершено чисто "
f"(HEALTHY, {checks_total} опросов).",
)
return
# DEGRADED -> decide + execute the reaction (§5), write artefact, finish.
action = post_deploy.decide_action(repo, verdict)
action_taken = post_deploy.ALERT_ONLY
if action == post_deploy.ROLLBACK:
# Non-self repo + auto policy: run the --rollback hook synchronously (the
# target is NOT the orchestrator, so its restart is safe for the pipeline).
exit_code, detail = post_deploy.run_rollback(repo)
action_taken = post_deploy.map_rollback_exit_code(exit_code)
if action_taken == post_deploy.ROLLBACK_OK:
_notify_post_deploy(
work_item_id,
f"⚠️ {work_item_id}: пост-деплой DEGRADED -> авто-rollback выполнен "
f"(exit {exit_code}).",
)
else:
# AC-9: a failed rollback escalates loudly for manual intervention.
_notify_post_deploy(
work_item_id,
f"🚨 {work_item_id}: пост-деплой DEGRADED -> авто-rollback ПРОВАЛИЛСЯ "
f"(exit {exit_code}: {detail}). Нужно ручное вмешательство.",
)
else:
# ALERT_ONLY: self-hosting ALWAYS lands here — the tick NEVER auto-rolls-back
# or restarts the prod orchestrator container (BR-5 / AC-8). Loud alert +
# manual-approve request (mirrors deploy Phase A CTA).
action_taken = post_deploy.ALERT_ONLY
_notify_post_deploy(
work_item_id,
f"🚨 {work_item_id}: пост-деплой DEGRADED ({checks_failed}/{checks_total} "
f"провалов). Требуется ручной approve отката — авто-rollback для "
f"self-hosting запрещён (BR-5).",
)
# ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for
# manual intervention. This is INDICATION ONLY — the tick NEVER restarts /
# rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5).
if work_item_id:
try:
set_issue_blocked(work_item_id)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}")
post_deploy.write_post_deploy_log(
repo, work_item_id, branch, post_deploy.DEGRADED, action_taken,
settings.post_deploy_window_s, checks_total, checks_failed,
)
post_deploy.mark_done(repo, work_item_id)
def _notify_post_deploy(work_item_id: str, message: str) -> None:
"""Best-effort Telegram + Plane notification for a post-deploy event (AC-17).
Never raises — a notification failure must not wedge the monitor tick.
"""
try:
send_telegram(message)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy notify telegram failed for {work_item_id}: {e}")
if work_item_id:
try:
plane_add_comment(work_item_id, message, author="deployer")
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}")

173
src/staging_verdict.py Normal file
View File

@@ -0,0 +1,173 @@
"""ORCH-061: pure staging-verdict logic (classification + tolerant verdict).
The self-hosting ``orchestrator`` looped on ``deploy-staging`` because
``scripts/staging_check.py`` summed ``all_ok = passed == total`` and exited
non-zero on ANY failed check — so two *infrastructure-only* failures (C9a branch
not found / C9b analyst-job not in queue, both caused by the SANDBOX bot accounts
not being members of the sandbox Plane project) produced ``staging_status:
FAILED`` → rollback ``deploy-staging → development`` → loop (ADR-001 §Context).
This module isolates the **pure verdict logic** so both outcomes are unit-testable
without a live staging stand or docker (TRZ §9):
* ``classify_check(label)`` — label → ``REAL`` | ``SANDBOX_INFRA`` (narrow,
allowlist-driven, fail-closed to ``REAL`` on anything unrecognised);
* ``compute_staging_verdict(items, infra_tolerant)`` — fold the per-check
pass/fail + category into a single ``StagingVerdict``.
It is a **leaf**: stdlib only, no I/O, no project imports — so it is safe to import
both from the orchestrator process and from ``scripts/staging_check.py`` (which
runs inside the ``orchestrator-staging`` container, pattern B6 / ORCH-048). Every
public function honours a **never-raise** contract: on any malformed input it
returns the *conservative* (fail-closed) result, never an exception.
Safety invariant (FR-4 / AC-3): a failed REAL check ALWAYS yields ``FAILED`` /
exit 1 regardless of ``infra_tolerant``. The waiver applies ONLY to the named
``SANDBOX_INFRA`` checks and ONLY when every REAL check (incl. C7/C8) is green —
so the blast-radius of the tolerance is exactly the two allowlisted checks.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Category constants ---------------------------------------------------------
REAL = "real" # a real pipeline check — fail-closed, always counts
SANDBOX_INFRA = "sandbox_infra" # known to depend on sandbox infra (waivable)
# Narrow allowlist of checks known to depend on sandbox infrastructure rather
# than the pipeline itself (ADR-001 §1). Matched by the check's leading label
# token, e.g. "C9a Branch appears in orchestrator-sandbox" -> token "C9a".
# Keep this set MINIMAL — every entry is a hole in the staging safety-net.
SANDBOX_INFRA_CHECKS = frozenset({"C9a", "C9b"})
def classify_check(label) -> str:
"""Classify a staging-check label as ``REAL`` or ``SANDBOX_INFRA``.
A label is ``SANDBOX_INFRA`` iff its leading whitespace-delimited token is one
of :data:`SANDBOX_INFRA_CHECKS` (exact match or prefix, e.g. ``"C9a"`` from
``"C9a Branch appears…"``). Everything else — and anything unrecognised /
malformed — is ``REAL`` (conservative / fail-closed: an unknown check counts
toward the safety net). Never raises.
"""
try:
text = str(label).strip()
if not text:
return REAL
token = text.split()[0]
for prefix in SANDBOX_INFRA_CHECKS:
if token == prefix or token.startswith(prefix):
return SANDBOX_INFRA
return REAL
except Exception:
return REAL
@dataclass
class StagingVerdict:
"""Outcome of folding the staging-check suite into a single verdict.
``status`` — ``"SUCCESS"`` | ``"FAILED"`` (mirrors the ``staging_status:``
frontmatter contract the deployer writes; unchanged).
``exit_code`` — ``0`` (advance) | ``1`` (rollback). Drives ``sys.exit`` in
``staging_check.py``.
``waived`` — labels of SANDBOX_INFRA checks that failed but were tolerated
(empty unless the waiver actually fired — observability, FR-7).
``summary`` — human-readable one-liner for logs.
"""
status: str
exit_code: int
waived: list = field(default_factory=list)
summary: str = ""
def _coerce_item(item) -> tuple[str, bool, str]:
"""Normalise an input row into ``(label, passed, category)``.
Accepts ``(label, passed)`` or ``(label, passed, category)``. A missing/None
category is resolved via :func:`classify_check`. Never raises — a malformed
row degrades to a failed REAL check (fail-closed) so it cannot silently pass.
"""
try:
label = str(item[0])
passed = bool(item[1])
category = item[2] if len(item) > 2 and item[2] else None
except Exception:
return ("<malformed>", False, REAL)
if category not in (REAL, SANDBOX_INFRA):
category = classify_check(label)
return (label, passed, category)
def compute_staging_verdict(items, infra_tolerant: bool) -> StagingVerdict:
"""Fold per-check results into a tolerant-but-fail-closed staging verdict.
``items`` — iterable of ``(label, passed: bool[, category: str])``.
Decision table (ADR-001 §1):
* any REAL check failed -> FAILED / exit 1 (safety net)
* only SANDBOX_INFRA failed & infra_tolerant -> SUCCESS / exit 0 (waived)
* only SANDBOX_INFRA failed & !infra_tolerant -> FAILED / exit 1 (legacy strict)
* nothing failed -> SUCCESS / exit 0
Never raises: on any internal error the verdict degrades to a conservative
``FAILED`` / exit 1 (never a false green) — AC-10.
"""
try:
real_failed: list[str] = []
infra_failed: list[str] = []
for raw in items:
label, passed, category = _coerce_item(raw)
if passed:
continue
if category == SANDBOX_INFRA:
infra_failed.append(label)
else:
real_failed.append(label)
if real_failed:
# Safety net (FR-4): a real pipeline regression always fails closed,
# regardless of tolerance. Infra failures (if any) are noted but the
# verdict is dominated by the real failure.
extra = f"; infra-fail {infra_failed}" if infra_failed else ""
return StagingVerdict(
status="FAILED",
exit_code=1,
waived=[],
summary=f"FAILED: real checks failed {real_failed}{extra}",
)
if infra_failed and infra_tolerant:
# Waiver fires ONLY here: every REAL check is green and the only
# failures are allowlisted sandbox-infra checks (FR-2).
return StagingVerdict(
status="SUCCESS",
exit_code=0,
waived=list(infra_failed),
summary=(
f"SUCCESS (infra-waived): {infra_failed} are known sandbox-infra "
"checks; all real checks green"
),
)
if infra_failed and not infra_tolerant:
# Legacy strict (kill-switch off): any failure fails closed (1:1 pre-061).
return StagingVerdict(
status="FAILED",
exit_code=1,
waived=[],
summary=f"FAILED (strict): {infra_failed} failed and tolerance disabled",
)
return StagingVerdict(
status="SUCCESS",
exit_code=0,
waived=[],
summary="SUCCESS: all checks green",
)
except Exception as e: # noqa: BLE001 - never-raise; fail closed on doubt
return StagingVerdict(
status="FAILED",
exit_code=1,
waived=[],
summary=f"FAILED (verdict error, fail-closed): {e}",
)

View File

@@ -147,10 +147,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
return
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
# both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
# ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point),
# which discharges `In Progress` of its overloaded "start the pipeline"
# meaning. Fail-closed: on a project without the `To Analyse` status,
# `to_analyse` aliases to the project's own `in_progress` UUID, so moving an
# enduro issue to In Progress still triggers start/resume (AC-17).
proj_states = get_project_states(project_id)
if new_state == proj_states["in_progress"]:
if new_state == proj_states["to_analyse"]:
await handle_status_start(data, project_id)
elif new_state == proj_states["approved"]:
await handle_verdict(data, project_id, approved=True)
@@ -235,9 +240,14 @@ async def handle_status_start(data: dict, project_id: str = ""):
)
job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id)
logger.info(
f"Task {task_id}: returned to In Progress (Needs Input answered), "
f"Task {task_id}: returned to To Analyse (Needs Input answered), "
f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})"
)
# ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is
# re-indicated as `Analysis`; other stages keep their own indication.
if current_stage == "analysis":
from ..plane_sync import set_issue_analysis as _set_analysis
_set_analysis(work_item_id)
try:
_add_comment(
work_item_id,
@@ -538,6 +548,10 @@ async def start_pipeline(data: dict, project_id: str = ""):
)
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
# ORCH-066 (AC-3): indicate the analysis stage with the dedicated
# `Analysis` status (degrades to In Progress where it is not created).
from ..plane_sync import set_issue_analysis as _set_analysis
_set_analysis(work_item_id, plane_project_id)
# Post start comment to Plane
from ..plane_sync import add_comment as _add_comment
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
@@ -579,9 +593,11 @@ async def _rollback_stage(
(via the existing rollback notify + an enqueue of the prev-stage agent).
"""
if current_stage == "analysis":
# Already in analysis — just relaunch analyst with rejection reason
from ..plane_sync import set_issue_in_progress
set_issue_in_progress(work_item_id)
# Already in analysis — just relaunch analyst with rejection reason.
# ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the
# status is not created).
from ..plane_sync import set_issue_analysis
set_issue_analysis(work_item_id)
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. "

View File

@@ -142,3 +142,105 @@ def test_image_freshness_settings_env_override(monkeypatch):
s = Settings()
assert s.image_freshness_enabled is False
assert s.image_freshness_repos == "orchestrator,enduro-trails"
# ---------------------------------------------------------------------------
# ORCH-061 / TC-09: staging_infra_tolerance_enabled kill-switch (AC-7).
# ---------------------------------------------------------------------------
def test_staging_infra_tolerance_defaults_true(monkeypatch):
"""TC-09 / AC-7: the kill-switch defaults ON (safe default — the safety net
holds regardless; the flag exists to restore legacy strictness instantly)."""
monkeypatch.delenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", raising=False)
assert Settings().staging_infra_tolerance_enabled is True
def test_staging_infra_tolerance_env_override_false(monkeypatch):
"""TC-09 / AC-7: ORCH_STAGING_INFRA_TOLERANCE_ENABLED=false -> strict (1:1
pre-ORCH-061: infra-only FAIL again rolls back)."""
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "false")
assert Settings().staging_infra_tolerance_enabled is False
def test_staging_infra_tolerance_env_override_true(monkeypatch):
"""The field is read verbatim from its ORCH_* env var."""
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true")
assert Settings().staging_infra_tolerance_enabled is True
# ---------------------------------------------------------------------------
# ORCH-065 / TC-20: reaper_* + lease_reclaim_* settings defaults + env override.
# ---------------------------------------------------------------------------
_REAPER_ENV = (
"ORCH_REAPER_ENABLED",
"ORCH_REAPER_INTERVAL_S",
"ORCH_REAPER_DEAD_TICKS",
"ORCH_REAPER_MAX_RUNNING_S",
"ORCH_LEASE_RECLAIM_ENABLED",
)
def test_reaper_settings_defaults(monkeypatch):
"""TC-20 / §5: documented defaults when no env is set."""
for name in _REAPER_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.reaper_enabled is True
assert s.reaper_interval_s == 60
assert s.reaper_dead_ticks == 2
assert s.reaper_max_running_s == 3600
assert s.lease_reclaim_enabled is True
def test_reaper_settings_env_override(monkeypatch):
"""TC-20 / §5 / AC-14: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_REAPER_ENABLED", "false")
monkeypatch.setenv("ORCH_REAPER_INTERVAL_S", "30")
monkeypatch.setenv("ORCH_REAPER_DEAD_TICKS", "5")
monkeypatch.setenv("ORCH_REAPER_MAX_RUNNING_S", "1200")
monkeypatch.setenv("ORCH_LEASE_RECLAIM_ENABLED", "false")
s = Settings()
assert s.reaper_enabled is False
assert s.reaper_interval_s == 30
assert s.reaper_dead_ticks == 5
assert s.reaper_max_running_s == 1200
assert s.lease_reclaim_enabled is False
# ---------------------------------------------------------------------------
# ORCH-065 / TC-19: contracts unchanged — no new stages / QG checks; the
# check_branch_mergeable signature is intact (AC-13).
# ---------------------------------------------------------------------------
def test_tc19_stage_transitions_unchanged():
"""No new pipeline stage was introduced by ORCH-065."""
from src.stages import STAGE_TRANSITIONS
assert set(STAGE_TRANSITIONS) == {
"created", "analysis", "architecture", "development", "review",
"testing", "deploy-staging", "deploy", "done",
}
def test_tc19_qg_checks_registry_unchanged():
"""No new quality-gate check was added to the registry by ORCH-065."""
from src.qg.checks import QG_CHECKS
assert set(QG_CHECKS) == {
"check_analysis_approved",
"check_analysis_complete",
"check_architecture_done",
"check_ci_green",
"check_review_approved",
"check_tests_passed",
"check_reviewer_verdict",
"check_tests_local",
"check_deploy_status",
"check_staging_status",
"check_branch_mergeable",
"check_staging_image_fresh",
}
def test_tc19_check_branch_mergeable_signature_intact():
"""check_branch_mergeable still takes exactly (repo, work_item_id, branch)."""
import inspect
from src.qg.checks import check_branch_mergeable
params = list(inspect.signature(check_branch_mergeable).parameters)
assert params == ["repo", "work_item_id", "branch"]

View File

@@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch):
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -127,6 +130,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
assert _jobs() == []
# The restart-safe approve-requested marker was written.
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`.
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036")
stage_engine.set_issue_in_review.assert_not_called()
# ---------------------------------------------------------------------------
@@ -151,6 +157,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
# The finalizer was enqueued.
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
# ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate.
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036")
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
res2 = advance_stage(

View File

@@ -102,6 +102,31 @@ def test_tc08_dockerfile_stamps_revision_label():
assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text
# ---------------------------------------------------------------------------
# TC-08b (ORCH-021 regression): the Dockerfile must not COPY a gitignored path.
# The ORCH-058 staging rebuild builds with the task *worktree* as the docker build
# context. A fresh worktree contains only tracked files, so any `COPY <gitignored>`
# (notably `data/`, the SQLite dir) makes `docker build` fail with exit 1 and bounces
# the task off `deploy-staging`. `data/` is a runtime bind-mount volume anyway, so it
# must never be a COPY source.
# ---------------------------------------------------------------------------
def test_tc08b_dockerfile_does_not_copy_gitignored_data_dir():
text = _DOCKERFILE.read_text(encoding="utf-8")
gitignore = (_ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
# Precondition: `data/` really is gitignored (the build context will not have it).
assert "data/" in [ln.strip() for ln in gitignore]
# The Dockerfile must not COPY it (would break the worktree-context staging build).
copy_sources = [
line.split()[1]
for line in text.splitlines()
if line.strip().upper().startswith("COPY") and len(line.split()) >= 3
]
assert "data/" not in copy_sources, (
"Dockerfile must not `COPY data/` — it's gitignored and absent from the "
"worktree build context used by the ORCH-058 staging rebuild (exit 1)."
)
# ---------------------------------------------------------------------------
# TC-09: caller↔hook contract — rebuild_staging_image builds the right command
# ---------------------------------------------------------------------------

View File

@@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch):
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -90,6 +93,10 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
# Spy the merge-lease release to confirm the terminal-sync still frees it.
release = MagicMock()
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", release)
# ORCH-021 arms an orthogonal post-deploy-monitor reserved job at deploy->done
# for the self-hosting repo; disable it here so this test stays focused on the
# ORCH-036 terminal-sync contract (no PIPELINE agent launched leaving deploy).
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
@@ -102,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
# No agent is launched leaving deploy (terminal).
assert _jobs() == []
# ---------------------------------------------------------------------------
# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy,
# NOT terminal Done. The post-deploy monitor finalises.
# ---------------------------------------------------------------------------
def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
# post_deploy applies for the self-hosting repo with the monitor enabled.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
# arm_monitor is orthogonal; stub it so this test stays on the status contract.
monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True))
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
# Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet.
stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036")
stage_engine.set_issue_done.assert_not_called()
# ---------------------------------------------------------------------------
# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress).
# ---------------------------------------------------------------------------
def test_tc09_non_self_deploy_done_sets_done(monkeypatch):
self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
# Monitor enabled, but the empty CSV means it applies ONLY to the self repo;
# a non-self repo therefore takes the unchanged terminal-Done path.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
stage_engine.set_issue_done.assert_called_once_with("ET-042")
stage_engine.set_issue_monitoring.assert_not_called()

388
tests/test_job_reaper.py Normal file
View File

@@ -0,0 +1,388 @@
"""ORCH-065: job-reaper unit tests (TC-01..TC-08, TC-21).
The reaper never spawns claude; we drive the DB directly (a 'running' jobs row +
optional agent_runs exit_code/pid) and assert the terminal flip + side-effects.
``os.kill`` liveness is monkeypatched so a 'dead'/'alive' pid is deterministic.
"""
import os
import tempfile
import pytest
# Override env before importing app modules (same convention as test_queue.py).
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_reaper.db")
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
import src.db as db
from src.db import init_db, get_db, enqueue_job, get_job
import src.job_reaper as jr
from src.job_reaper import JobReaper
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "reaper.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
init_db()
yield
# --- helpers ----------------------------------------------------------------
def _make_running_job(agent="developer", repo="orchestrator", task_id=None,
pid=None, age_s=0, attempts=0, max_attempts=2,
run_id=None, exit_code=None, finished_age_s=600):
"""Insert a job already in 'running' with the given pid/age/attempts.
started_at is back-dated by ``age_s`` seconds so running_age_s reflects it.
When ``exit_code`` is given an agent_runs row is created and linked (Tier-2);
its ``finished_at`` is back-dated by ``finished_age_s`` seconds so the
Tier-2 finalization grace (``reaper_finalize_grace_s``, default 300) is
satisfied by default — pass a small ``finished_age_s`` to exercise the
"monitor may still be finalizing" deferral.
"""
conn = get_db()
if run_id is None and exit_code is not None:
cur = conn.execute(
"INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) "
"VALUES (?, ?, datetime('now', ?), ?)",
(task_id, agent, f"-{int(finished_age_s)} seconds", exit_code),
)
run_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, "
"run_id, pid, started_at) "
"VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))",
(agent, repo, task_id, attempts, max_attempts, run_id, pid,
f"-{int(age_s)} seconds"),
)
job_id = cur.lastrowid
conn.commit()
conn.close()
return job_id
def _make_task(repo="orchestrator", branch="feature/x", stage="development",
work_item_id="ORCH-1"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(work_item_id, work_item_id, repo, branch, stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _dead_pid(monkeypatch):
"""Force merge_gate.pid_alive -> False (process gone) for the reaper."""
import src.merge_gate as mg
monkeypatch.setattr(mg, "pid_alive", lambda pid: False)
def _alive_pid(monkeypatch):
import src.merge_gate as mg
monkeypatch.setattr(mg, "pid_alive", lambda pid: True)
# --- TC-01: dead executor -> reaped without process restart -----------------
def test_tc01_dead_pid_reaped_to_queued(monkeypatch):
_dead_pid(monkeypatch)
jid = _make_running_job(pid=999999, attempts=0, max_attempts=2)
r = JobReaper()
r.reap_once() # tick 1 (streak=1, dead_ticks default 2 -> not yet)
assert get_job(jid)["status"] == "running"
r.reap_once() # tick 2 -> reaped
assert get_job(jid)["status"] == "queued"
assert r.reaped_total == 1
assert r.last_reaped["job_id"] == jid
# --- TC-02: live agent within timeout is NEVER reaped -----------------------
def test_tc02_alive_pid_never_reaped(monkeypatch):
_alive_pid(monkeypatch)
jid = _make_running_job(pid=4321, age_s=10)
r = JobReaper()
for _ in range(5):
r.reap_once()
assert get_job(jid)["status"] == "running"
assert r.reaped_total == 0
def test_tc02_alive_within_max_running_not_reaped(monkeypatch):
_alive_pid(monkeypatch)
monkeypatch.setattr(db.settings, "reaper_max_running_s", 3600)
jid = _make_running_job(pid=4321, age_s=1800) # < ceiling, alive
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "running"
# --- TC-03: zombie only after reaper_dead_ticks consecutive ticks -----------
def test_tc03_requires_consecutive_dead_ticks(monkeypatch):
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 3)
import src.merge_gate as mg
# Dead, dead, ALIVE (resets), dead, dead, dead -> reaped only on the 6th tick.
seq = iter([False, False, True, False, False, False])
monkeypatch.setattr(mg, "pid_alive", lambda pid: next(seq))
jid = _make_running_job(pid=999998)
r = JobReaper()
for _ in range(5):
r.reap_once()
assert get_job(jid)["status"] == "running"
r.reap_once() # 6th tick: third CONSECUTIVE dead -> reaped
assert get_job(jid)["status"] == "queued"
# --- TC-04: backstop ceiling reaps even when liveness is unknown ------------
def test_tc04_backstop_ceiling(monkeypatch):
_alive_pid(monkeypatch) # liveness says "alive", but age exceeds the ceiling
monkeypatch.setattr(db.settings, "reaper_max_running_s", 100)
jid = _make_running_job(pid=4321, age_s=500)
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "queued"
assert r.reaped_total == 1
def test_tc04_backstop_no_pid(monkeypatch):
monkeypatch.setattr(db.settings, "reaper_max_running_s", 100)
jid = _make_running_job(pid=None, age_s=500)
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "queued"
# --- TC-05: correct outcome by exit_code (Tier-2) ---------------------------
def _gate(monkeypatch, green: bool):
"""Force the reaper's READ-ONLY gate pre-evaluation to green/red."""
monkeypatch.setattr(
JobReaper, "_gate_is_green",
lambda self, stage, job, branch, wid: green,
)
def test_tc05_exit0_gate_green_done(monkeypatch):
# A developer job runs to LEAVE the 'architecture' stage (-> 'development').
tid = _make_task(stage="architecture")
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0)
_gate(monkeypatch, green=True)
# gate green -> the claim flips 'done' FIRST, then the advance runs.
import src.agents.launcher as L
monkeypatch.setattr(
L.launcher, "_try_advance_stage",
lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"),
)
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "done"
def test_tc05_exit0_gate_red_requeues(monkeypatch):
tid = _make_task(stage="architecture")
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
attempts=0, max_attempts=2)
_gate(monkeypatch, green=False) # read-only pre-eval says red
# The advance path must NEVER run when the gate is red (claim-before-act).
import src.agents.launcher as L
called = []
monkeypatch.setattr(L.launcher, "_try_advance_stage",
lambda run_id, agent, repo, branch: called.append(1))
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "queued" # exit0 but gate red -> not 'done'
assert not called, "no advance/side-effects on a red gate"
def test_tc05_exit0_already_advanced_done_no_side_effects(monkeypatch):
# Stage already past the developer candidate set -> idempotent clean 'done'
# with NO advance call (the monitor already advanced before dying).
tid = _make_task(stage="development") # developer's candidate is 'architecture'
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0)
import src.agents.launcher as L
called = []
monkeypatch.setattr(L.launcher, "_try_advance_stage",
lambda run_id, agent, repo, branch: called.append(1))
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "done"
assert not called, "already-advanced reap must not re-advance"
def test_tc05_nonzero_exit_requeue_then_failed(monkeypatch):
sent = []
monkeypatch.setattr(jr, "JobReaper", JobReaper)
tid = _make_task(stage="development")
jid = _make_running_job(agent="developer", task_id=tid, exit_code=1,
attempts=1, max_attempts=2)
r = JobReaper()
import src.notifications as notif
monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: sent.append(a))
r.reap_once() # attempts(1) < max(2) -> queued
assert get_job(jid)["status"] == "queued"
# Now exhaust the budget.
jid2 = _make_running_job(agent="developer", task_id=tid, exit_code=1,
attempts=2, max_attempts=2)
r.reap_once()
assert get_job(jid2)["status"] == "failed"
assert sent, "failed reap must send a Telegram alert"
# --- TC-05b: Tier-2 finalization grace (live monitor still finalizing) -------
def test_tc05_tier2_within_grace_not_reaped(monkeypatch):
"""exit_code freshly recorded -> a LIVE monitor may still be finalizing.
The reaper must NOT reap it within ``reaper_finalize_grace_s`` (FR-1.3/AC-3:
a live finalizing monitor — git push / PR / Plane comments — is never reaped,
no dup advance / enqueue).
"""
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
tid = _make_task(stage="architecture")
# exit_code recorded only 5s ago -> still inside the finalization grace.
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
finished_age_s=5)
import src.agents.launcher as L
called = []
monkeypatch.setattr(L.launcher, "_try_advance_stage",
lambda run_id, agent, repo, branch: called.append(1))
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "running" # deferred, NOT reaped
assert r.reaped_total == 0
assert not called, "a live finalizing monitor must not be advanced by the reaper"
def test_tc05_tier2_after_grace_reaped(monkeypatch):
"""Once exit_code has been recorded longer than the grace, the monitor is
genuinely dead and the Tier-2 reap proceeds."""
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
tid = _make_task(stage="architecture")
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
finished_age_s=600) # well past the grace
_gate(monkeypatch, green=True)
import src.agents.launcher as L
monkeypatch.setattr(
L.launcher, "_try_advance_stage",
lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"),
)
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "done"
def test_tc05_tier2_lost_claim_no_side_effects(monkeypatch):
"""claim-BEFORE-act: when another actor (a late monitor / startup requeue)
moves the row out of 'running' AFTER the reaper read it but BEFORE the atomic
claim, the reaper's claim loses (rowcount==0) and it performs NO advance side
effects (no dup advance / dup enqueue) — ADR-001 Р-1."""
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 0)
tid = _make_task(stage="architecture")
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
finished_age_s=10)
import src.agents.launcher as L
called = []
monkeypatch.setattr(L.launcher, "_try_advance_stage",
lambda run_id, agent, repo, branch: called.append(1))
# The read-only gate pre-eval reports green, but the row is concurrently
# claimed by someone else right before the reaper's atomic claim runs.
def green_then_steal(self, stage, job, branch, wid):
db.requeue_running_jobs() # another actor wins the 'running' row first
return True
monkeypatch.setattr(JobReaper, "_gate_is_green", green_then_steal)
r = JobReaper()
r.reap_once()
# Reaper lost the atomic claim -> no advance, no double work. The row stays
# where the winner left it ('queued'), not flipped to 'done' by the reaper.
assert not called, "reaper that lost the claim must not advance/enqueue"
assert get_job(jid)["status"] == "queued"
assert r.reaped_total == 0
# --- TC-06: atomicity — reaper vs requeue_running_jobs (status guard) --------
def test_tc06_atomic_no_double_reap(monkeypatch):
_dead_pid(monkeypatch)
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
jid = _make_running_job(pid=999997, attempts=0, max_attempts=2)
# Simulate the startup requeue winning the row first.
n = db.requeue_running_jobs()
assert n == 1
assert get_job(jid)["status"] == "queued"
# The reaper now scans: the row is no longer 'running' -> reap_running_job's
# WHERE status='running' guard yields rowcount 0 -> no second processing.
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "queued"
assert r.reaped_total == 0
def test_tc06_reap_running_job_guard_returns_false_when_not_running():
jid = enqueue_job("developer", "orchestrator") # status 'queued', not running
assert db.reap_running_job(jid, "done") is False
assert get_job(jid)["status"] == "queued"
# --- TC-07: kill-switch reaper_enabled=False -> no-op -----------------------
def test_tc07_kill_switch(monkeypatch):
_dead_pid(monkeypatch)
monkeypatch.setattr(db.settings, "reaper_enabled", False)
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", False)
jid = _make_running_job(pid=999996, age_s=99999)
r = JobReaper()
for _ in range(3):
r.reap_once()
assert get_job(jid)["status"] == "running"
assert r.reaped_total == 0
# --- TC-08: never-raise — a DB/OS error in one tick does not propagate -------
def test_tc08_never_raise_isolates_per_job(monkeypatch):
_dead_pid(monkeypatch)
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
good = _make_running_job(pid=111, attempts=0, max_attempts=2)
bad = _make_running_job(pid=222, attempts=0, max_attempts=2)
r = JobReaper()
orig = r._reap_job
def boom(job):
if job["id"] == bad:
raise RuntimeError("simulated per-job failure")
return orig(job)
monkeypatch.setattr(r, "_reap_job", boom)
# Must not raise despite the bad job blowing up.
r.reap_once()
# The good job is still reaped; the bad one is isolated (stays running).
assert get_job(good)["status"] == "queued"
assert get_job(bad)["status"] == "running"
def test_tc08_reap_once_outer_never_raises(monkeypatch):
monkeypatch.setattr(jr, "get_running_jobs",
lambda: (_ for _ in ()).throw(RuntimeError("db down")))
r = JobReaper()
# reap_once swallows... actually get_running_jobs is iterated in the for; the
# _tick wrapper guarantees the loop never dies. Assert _tick is safe.
r._tick()
assert r.last_run_ts is not None
# --- TC-21: startup lease-reclaim + reaper start/stop smoke -----------------
def test_tc21_reaper_start_stop_smoke():
r = JobReaper(interval_s=0.05)
r.start()
assert r._thread is not None and r._thread.is_alive()
r.stop(timeout=2)
assert not r._thread.is_alive()
def test_tc21_reclaim_all_stale_leases_callable(monkeypatch):
# No lease files present -> 0 reclaimed, never raises (registration smoke).
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", True)
assert jr.reclaim_all_stale_leases() == 0

View File

@@ -278,3 +278,48 @@ class TestWatchdogGracefulKill:
assert signal.SIGKILL not in sent
assert recorded["called"] is False
# ---------------------------------------------------------------------------
# ORCH-061 / TC-06 + TC-07: "no changes to commit" on an action stage is EXPECTED,
# not under-delivery (FR-3 / AC-4). action_stage_no_changes_note is the PURE
# observability decision used by the post-run no-changes branch: it returns an
# explicit note for self-deploy action stages (deploy-staging/deploy) and None
# everywhere else. It NEVER signals a rollback — advancement is decided by the
# exit-code + gate verdict, never by a commit existing.
# ---------------------------------------------------------------------------
from src.agents.launcher import action_stage_no_changes_note # noqa: E402
class TestActionStageNoChangesNote:
def test_tc06_deploy_staging_self_deploy_returns_note(self):
"""TC-06 / AC-4: on deploy-staging for the self-hosting repo, an empty diff
yields an explicit "expected on action stage" note (no rollback signal)."""
note = action_stage_no_changes_note("deploy-staging", "orchestrator")
assert note is not None
assert "deploy-staging" in note
assert "expected on action stage" in note
def test_tc06_deploy_self_deploy_returns_note(self):
"""The `deploy` stage is equally an action stage for self-deploy."""
note = action_stage_no_changes_note("deploy", "orchestrator")
assert note is not None
assert "deploy: no code changes" in note
def test_tc07_development_stage_returns_none(self):
"""TC-07 / AC-4 regression-guard: on a CODE stage (development) the new
action-stage allowance does NOT apply — no note, behaviour unchanged."""
assert action_stage_no_changes_note("development", "orchestrator") is None
def test_tc06_non_self_repo_returns_none(self):
"""Conditionality (FR-5): the action-stage allowance is self-deploy only;
a non-self repo on deploy-staging gets no special note."""
assert action_stage_no_changes_note("deploy-staging", "enduro-trails") is None
def test_review_stage_returns_none(self):
"""Any non-action stage -> None (defensive: only deploy stages qualify)."""
assert action_stage_no_changes_note("review", "orchestrator") is None
def test_never_raises_on_bad_input(self):
"""never-raise: odd inputs (None stage / None repo) degrade to None."""
assert action_stage_no_changes_note(None, None) is None

View File

@@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
_PROJECT_STATES = {
ENDURO_PLANE_ID: {
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
# ORCH-066: To Analyse is the start trigger; with the status absent it
# aliases to in_progress (the real get_project_states fallback).
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
},
ORCH_PLANE_ID: {
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
},

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