Compare commits

...

65 Commits

Author SHA1 Message Date
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
6ddff5583d fix(ORCH-058): parametrize staging_check in --build-staging + explicit staging target
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 18s
Round-3 review follow-up on c53d625 (P1/P2):

- P1: --build-staging now runs staging_check via parametrized
  STAGING_CONTAINER / STAGING_CHECK_PATH / STAGING_CHECK_MODE (default
  orchestrator-staging / bind-mount path / stub) instead of hardcoding
  $TARGET_SERVICE + the script path. docker exec runs INSIDE the staging
  container (ORCH-048 canonical: B6 registry isolation), after health,
  before exit 0. Fail-closed: any non-zero -> exit 1. STAGING only (8501).
- P2a: rebuild_staging_image now passes the STAGING target EXPLICITLY
  (TARGET_SERVICE/TARGET_PORT/COMPOSE_PROFILE/STAGING_CONTAINER) so the
  self-rebuild can never drift onto prod 8500 if hook defaults change (AC-9).
- P2b: TC-09 caller<->hook contract tests assert the ssh command carries
  GIT_SHA + BUILD_CONTEXT + the staging target and never the prod 8500 one;
  no-ssh-host fails closed.
- P3: consolidated the three duplicate README footers into one.
- Docs (golden source): DEPLOY_HOOK.md step 4 + env rows, README footer,
  CHANGELOG, Dockerfile ARG GIT_SHA="" comment, .env.example freshness block.

Validates exactly the artefact later BUILD-ONCE retagged to prod (AC-4,
ADR-001 step 3). 632 tests pass, ruff clean, bash -n OK.

Refs: ORCH-058

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 09:24:38 +00:00
Dev Agent
c53d625744 ORCH-058: --build-staging runs staging_check.py --mode stub vs fresh 8501 (AC-4)
All checks were successful
CI / test (push) Successful in 16s
Per ADR-001 step 3 / AC-4: after the freshly rebuilt staging container is
healthy, run staging_check.py --mode stub against the fresh 8501 stand BEFORE
reporting success, so the EXACT artefact BUILD-ONCE retagged to prod is the one
validated on staging. Fail-closed: staging_check rc!=0 -> exit 1 (not promoted).

- Invoked inside the container (docker exec $TARGET_SERVICE) per the canonical
  signature in scripts/staging_check.py header, --base-url http://localhost:$TARGET_PORT.
- Targets ONLY 8501 (staging), never 8500 (prod) - AC-9.
- --mode stub: fast, deterministic, no LLM spend (ADR).
- Static regression test test_tc07_build_staging_runs_staging_check_stub_after_health:
  asserts staging_check.py + --mode stub present, runs after health, before exit 0,
  fail-closed, and never hard-codes prod 8500.
2026-06-07 12:11:07 +03:00
2ee06ae676 fix(deploy-hook): --build-staging must build from validated worktree, recreate+health 8501
All checks were successful
CI / test (push) Successful in 17s
Closes reviewer P0/P1 (ORCH-058 attempt 3): the committed --build-staging hook
recomputed GIT_SHA=$(git rev-parse HEAD) in $REPO (prod clone on `main`) and built
`docker build ... "$REPO"`, ignoring the caller-supplied BUILD_CONTEXT/GIT_SHA. On
the deploy-staging -> deploy edge the PR is not yet merged, so `main` HEAD != the
validated SHA -> the staging image got the wrong revision label and Strategy-B's
guard fail-closed on EVERY valid self-deploy (AC-6 deadlock). It also only did
`docker build` + exit 0 — never recreating 8501 nor health-checking — so
rebuild_staging_image's rc=0 ("rebuilt and healthy") was a lie (AC-4 unmet).

- Hook --build-staging now honours caller BUILD_CONTEXT (validated worktree) and
  GIT_SHA, recreates orchestrator-staging on the fresh image and runs the 10x6s
  health-check; build/health failure -> exit 1 (FAILED contract preserved).
- image_freshness.rebuild_staging_image: document why COMPOSE_PROFILE/TARGET_SERVICE/
  TARGET_PORT are intentionally omitted (hook STAGING defaults -> 8501 only, P2).
- tests: assert the caller<->hook contract (builds from $BUILD_CONTEXT, no
  `git rev-parse HEAD` recompute, recreates + health-checks 8501) so the P0
  regression can't pass green again (P1).

Refs: ORCH-058

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 08:37:51 +00:00
3b3d587300 docs(ORCH-058): add CHANGELOG entry, .env.example flags, fix README status
All checks were successful
CI / test (push) Successful in 17s
Close AC-11 documentation gap left by the prior developer run: the
ORCH-058 feature (staging-image provenance before BUILD-ONCE retag) was
implemented and green but never recorded in the golden-source docs.

- CHANGELOG.md: add the ORCH-058 [Unreleased]/Added entry (layers A+B,
  validated_revision anchor, check_staging_image_fresh, EXPECTED_REVISION
  hook guard, new ORCH_IMAGE_FRESHNESS_* flags, ADR/test refs).
- .env.example (canon): document ORCH_IMAGE_FRESHNESS_ENABLED /
  ORCH_IMAGE_FRESHNESS_REPOS, mirroring the ORCH-036/043/053 precedent.
- docs/architecture/README.md: footer note design -> реализовано, aligning
  it with the already-updated section.

Refs: ORCH-058

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 08:27:57 +00:00
Dev Agent
f0c2986477 ORCH-058: implement fail-closed provenance guard in deploy-hook + GIT_SHA OCI label in Dockerfile
All checks were successful
CI / test (push) Successful in 16s
- deploy-hook: REVISION_LABEL/EXPECTED_REVISION (default unset -> backward-compat)
- deploy-hook: fail-closed guard inspects SOURCE_IMAGE revision label before docker tag, normalises <no value>, exit 1 on empty/mismatch
- deploy-hook: new --build-staging mode rebuilds staging image stamping GIT_SHA
- Dockerfile: ARG GIT_SHA + LABEL org.opencontainers.image.revision=$GIT_SHA

Closes TC07/TC08 (tests/test_deploy_hook_provenance.py).
2026-06-07 11:20:38 +03:00
83397570fe developer(ET): auto-commit from developer run_id=264
Some checks failed
CI / test (push) Failing after 17s
2026-06-07 07:46:19 +00:00
dbc32fc106 architect(ET): auto-commit from architect run_id=263
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 07:27:38 +00:00
282636fedb analyst(ET): auto-commit from analyst run_id=262
All checks were successful
CI / test (push) Successful in 16s
2026-06-07 07:06:10 +00:00
e5f9c38e65 docs: init ORCH-058 business request
All checks were successful
CI / test (push) Successful in 17s
2026-06-07 10:01:11 +03:00
stream
e4c6401633 docs(history): LESSONS self-deploy bootstrap — каскад 4 инфра-багов (passwd/env/log-perms/stale-staging-image)
Some checks failed
CI / test (push) Has been cancelled
2026-06-07 09:52:39 +03:00
stream
115519ebb4 fix(compose): ORCH_DEPLOY_* env for self-deploy (prefix ORCH_, orchestrator hook, host-repo path) — ORCH-36 Phase B 2026-06-07 09:39:51 +03:00
stream
64e031a37f fix(docker): passwd entry for uid 1000 (slin) — fixes ssh/whoami, unblocks ORCH-36 self-deploy Phase B 2026-06-07 09:27:04 +03:00
01ff71978f docs(ORCH-036): staging gate SUCCESS — 10/10 checks pass (re-run inside orchestrator-staging)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:48:50 +00:00
stream
d5915a89b9 docs(history): LESSONS ORCH-036+053 — bootstrap-деплой, merge-конфликт, reconciler в проде 2026-06-07 00:34:36 +03:00
1ff8d85bb9 Merge pull request 'feat: executable self-deploy — stage deploy triggers host hook (ORCH-036)' (#55) from feature/ORCH-036-orch-36-deploy-b into main 2026-06-07 00:23:40 +03:00
stream
36c1898fac Merge remote-tracking branch 'origin/main' into feature/ORCH-036-orch-36-deploy-b
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 14s
# Conflicts:
#	.env.example
#	CHANGELOG.md
#	docs/architecture/README.md
#	docs/operations/INFRA.md
#	src/config.py
2026-06-07 00:22:19 +03:00
e2dc9d6df6 Merge pull request 'ORCH-053: sweeper потерянных webhook (реконсиляция застрявших стадий)' (#56) from feature/ORCH-053-sweeper-webhook-stuck-task into main 2026-06-07 00:20:53 +03:00
c1196e34e8 deployer(ET): auto-commit from deployer run_id=204
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 15s
2026-06-06 21:04:39 +00:00
5089f99bb1 tester(ET): auto-commit from tester run_id=200
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 16s
2026-06-06 20:55:25 +00:00
32161a180a reviewer(ET): auto-commit from reviewer run_id=198 2026-06-06 20:55:25 +00:00
7d2d77217a feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)
Конвейер продвигается только входящими webhook; потерянное событие (502 на
ребилде, отсутствие ретраев у Plane/Gitea, неразрезолвленный sha→branch)
оставляет задачу молча застрявшей (класс инцидента ORCH-044). Новый фоновый
daemon-поток src/reconciler.py (паттерн queue_worker) доигрывает пропущенный
переход через те же штатные гейты/обработчики, что и webhook:

- F-1 gate-side: для задач stage≠done, без активного job и age(updated_at) ≥
  grace_for_stage(stage) — read-only пред-оценка канонического QG; зелёный →
  stage_engine.advance_stage(..., finished_agent=None); красный → тишина (спам
  нотификаций структурно невозможен). analysis F-1 не трогает (человеческий гейт).
- F-2 plane-side: опрос Plane API per-project (plane_sync.list_issues_by_state,
  курсорная пагинация, never-raise) → реплей In Progress/Approved/Rejected через
  существующие handle_status_start/handle_verdict (async из sync-потока, asyncio.run).
- F-3: усиление sha→branch в handle_ci_status — БД-fallback по единственной
  development-задаче repo (неоднозначность → не резолвим), debug→info.
- Анти-дубль на создании (db.create_task_atomic под process-wide Lock): гонка
  reconcile↔webhook не плодит второй task/branch/worktree/analyst-job (AC-4).
- F-4 observability: лог-строка разблокировки + Telegram + блок reconcile в /queue.

Старт/стоп в main.lifespan (после worker.start() / перед worker.stop()),
restart-safe, never-raise на единицу работы. Kill-switches ORCH_RECONCILE_ENABLED
/ ORCH_RECONCILE_PLANE_ENABLED + grace-настройки. Схема БД и реестры
STAGE_TRANSITIONS/QG_CHECKS не менялись.

Тесты: test_reconciler.py, test_reconciler_plane.py, test_gitea_sha_resolve.py,
test_config.py (33 новых, 563 всего зелёные). Документация обновлена (golden source):
architecture/README.md, INFRA.md, README.md, CHANGELOG.md, adr-0007 → accepted.

Refs: ORCH-053

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:55:25 +00:00
f5aae50514 architect(ET): auto-commit from architect run_id=194 2026-06-06 20:55:25 +00:00
a083ed8495 analyst(ET): auto-commit from analyst run_id=191 2026-06-06 20:55:25 +00:00
eac0eb4b3a docs: init ORCH-053 business request 2026-06-06 20:55:25 +00:00
112 changed files with 10809 additions and 45 deletions

View File

@@ -71,3 +71,72 @@ ORCH_DEPLOY_PROD_TARGET_PORT=8500
ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator
ORCH_DEPLOY_PROD_COMPOSE_PROFILE=
ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
# ORCH-058: staging-image provenance before the BUILD-ONCE prod retag (INV-FRESH).
# Guarantees the staging image promoted to prod is the EXACT artefact rebuilt from the
# validated commit — two layers, self-hosting only:
# A (liveness): QG sub-check `check_staging_image_fresh` on the deploy-staging->deploy
# edge rebuilds orchestrator-orchestrator-staging from the validated commit + recreates
# 8501; FAIL -> rollback to development. (builds/recreate STAGING only, never prod.)
# B (safety): the Dockerfile stamps `org.opencontainers.image.revision`; the prod hook
# fail-closes (exit 1) before `docker tag` if SOURCE_IMAGE's label != EXPECTED_REVISION.
# ENABLED -> single kill-switch for A+B as a WHOLE (never "B without A"); false -> legacy.
# REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting (orchestrator).
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
# retries, unresolved sha->branch).
# ENABLED -> global kill-switch (self-hosting safety / staged rollout).
# PLANE_ENABLED -> separate flag for the F-2 Plane-API poll (mute only F-2).
# INTERVAL_S -> background sweep period (seconds).
# 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-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

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

@@ -47,7 +47,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

@@ -1,11 +1,32 @@
FROM python:3.12-slim
# ORCH-058 (Strategy B): stamp the image with the git commit it was built from so
# the deploy hook can fail-close if a stale staging image would be promoted to prod
# (INV-FRESH). Passed at build time via `--build-arg GIT_SHA=<sha>` (the staging
# rebuild in check_staging_image_fresh / the --build-staging hook mode supplies it).
# Without the build-arg the label is empty -> the hook treats it as a mismatch
# (fail-closed). The OCI-standard key is read by `docker image inspect`.
ARG GIT_SHA=""
LABEL org.opencontainers.image.revision=$GIT_SHA
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/*
# git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it.
RUN git config --system --add safe.directory '*'
# ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base
# image has no passwd entry for uid 1000 -> ssh/whoami fail with
# "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh
# launch (ORCH-36 Phase B). Create a real user 1000 with a home dir so getpwuid()
# resolves and ssh can start.
RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bash slin
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

@@ -129,6 +129,13 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_TRANSIENT_MAX_ATTEMPTS` | Ретраи для 429/недоступности | `5` |
| `ORCH_BREAKER_THRESHOLD` | transient подряд до открытия breaker | `3` |
| `ORCH_BREAKER_PAUSE_SECONDS` | Пауза при открытом breaker | `300` |
| `ORCH_RECONCILE_ENABLED` | Kill-switch sweeper потерянных webhook (ORCH-053) | `true` |
| `ORCH_RECONCILE_PLANE_ENABLED` | Отдельный флаг F-2 (опрос Plane API) | `true` |
| `ORCH_RECONCILE_INTERVAL_S` | Период фонового прохода reconciler, сек | `120` |
| `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

@@ -26,9 +26,15 @@ services:
environment:
- ORCH_REPOS_DIR=/repos
- ORCH_HOST_REPOS_DIR=/home/slin/repos
# legacy enduro deployer (read via os.environ, keep as-is):
- DEPLOY_SSH_USER=slin
- DEPLOY_SSH_HOST=127.0.0.1
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
# ORCH-036 self-deploy (read via pydantic ORCH_ prefix; host-network -> 127.0.0.1, ssh key mounted):
- ORCH_DEPLOY_SSH_USER=slin
- ORCH_DEPLOY_SSH_HOST=127.0.0.1
- ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh
- ORCH_DEPLOY_HOST_REPO_PATH=/home/slin/repos/orchestrator
group_add:
- "999"

View File

@@ -11,6 +11,7 @@
- **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 не трогает (человеческий гейт). 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.
@@ -34,13 +35,23 @@ created → analysis → architecture → development → review → testing →
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
| done | — | — | — |
**Реестр QG** (`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 (ORCH-043).
**Реестр QG** (`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 (ORCH-043), check_staging_image_fresh (ORCH-058).
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
### Условный 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 завершился»).
@@ -80,6 +91,105 @@ 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-образ свеж и провалидирован». Этой гарантии нет:
конвейер нигде не пересобирает staging-образ из провалидированного коммита → retag мог тихо
промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча
откатывал прод). ORCH-058 обеспечивает инвариант `INV-FRESH` **двумя слоями** (defense in
depth), только для self-hosting:
- **A — пересборка (liveness):** детерминированный QG-под-чек `check_staging_image_fresh` на
ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A пересобирает
`orchestrator-orchestrator-staging` из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, OCI-лейбл `org.opencontainers.image.revision`), пересоздаёт
8501 и прогоняет `staging_check` против свежего образа → валидируем и промоутим один
артефакт. FAIL → откат на `development` (как merge-gate). Сборки/recreate — ТОЛЬКО staging.
- **B — fail-closed guard (safety):** хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл `revision`
у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`). Несовпадение
/ пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED (БАГ-8 откат),
прод не трогается. Делает тихий промоут устаревшего образа структурно невозможным даже при
отключённой/проигравшей гонку A.
Якорь «провалидированного коммита» — `git rev-parse HEAD` worktree ПОСЛЕ merge-gate (один
helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` B). Единый kill-switch
`image_freshness_enabled` включает A+B **как целое** (нет «B без A» = вечного fail-fast);
`image_freshness_repos` (пусто → self-hosting). `STAGE_TRANSITIONS`, exit-code хука (0/1/2),
`check_deploy_status`, БАГ-8, merge-gate, схема БД — НЕ меняются (под-гейт ребра + лейбл
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
(инцидент ORCH-044). Фоновый поток `reconciler` периодически (`reconcile_interval_s`)
находит застрявшие задачи и доигрывает пропущенный переход **через те же штатные
гейты/обработчики**, что и webhook:
- **F-1 gate-side:** для задач со `stage∉{done}`, без активного job и
`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 по единственной
development-задаче repo; неоднозначность → не резолвим).
- **F-4 observability:** при разблокировке — лог-строка `reconciler: <wi> <stage>
разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок
состояния в `GET /queue` (блок `reconcile`).
Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в
`main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед**
`worker.stop()`.
Инварианты: источник истины — гейт/Plane, не событие; идемпотентность (active-job
guard + atomic-claim на создании под process-wide Lock + grace + `max_concurrency=1`);
never-raise на единицу работы; тишина при синхронности; restart-safe; kill-switch
`ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2). Схема БД
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
[adr-0007](adr/adr-0007-reconciler.md), детально — `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`.
## Откаты
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
@@ -123,7 +233,7 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
|--------|------|----------|
| GET | `/health` | health check |
| GET | `/status` | активные задачи (stage != done) |
| GET | `/queue` | очередь: counts + max_concurrency + последние jobs |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + post_deploy (ORCH-021) + последние jobs |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
@@ -137,4 +247,4 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043. ORCH-036: исполняемый самодеплой стадии `deploy` — design (см. adr-0007), реализация в ветке feature/ORCH-036.*
*Актуально на 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-перехвата).*

View File

@@ -11,6 +11,16 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 |
| 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 |
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
> свободный номер (текущий максимум — `0010`).
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

View File

@@ -0,0 +1,77 @@
# adr-0007: Reconciler застрявших стадий (sweeper потерянных webhook)
- **Статус:** accepted (реализовано в `src/reconciler.py`)
- **Дата:** 2026-06-06
- **Задача:** ORCH-053
- **Детальный ADR:** `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`
## Контекст
Конвейер продвигается **только** входящими webhook (Plane status / Gitea CI/PR).
Потерянное событие (502 на ребилде, отсутствие ретраев у Plane/Gitea,
неразрезолвленный `sha→branch`) → источник истины изменился, а стадия задачи —
нет; задача застревает молча (инцидент ORCH-044). Существующий resilience
(`requeue_running_jobs`, orphan-recovery, events de-dup ORCH-5, `ci_poll`
ORCH-045) работает на уровне jobs/agent_runs и **не реконсилирует**
рассинхрон «источник истины ≠ стадия задачи».
## Решение
Фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`, module-singleton,
`threading.Event`), стартует в `main.lifespan` после `worker.start()`, стоп в
`finally` перед `worker.stop()`. Две взаимодополняющие ветки на каждом тике
(`reconcile_interval_s`, дефолт 120с):
- **F-1 gate-side** (локальная БД): для каждой `task` где `stage∉{done}`, **нет**
активного job, `age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка
канонического QG стадии; если зелёный → продвижение **штатным**
`stage_engine.advance_stage(..., finished_agent=None)` (тот же путь, что у Plane
Approved-webhook). Красный → **тишина** (нет advance, нет нотификаций — спам
структурно невозможен). `analysis` F-1 **не** реконсилирует (человеческий гейт →
отдан F-2).
- **F-2 plane-side** (опрос Plane API per-project через `list_issues_by_state`):
`In Progress`+нет задачи → `handle_status_start`; `Approved`+не сдвинута →
`handle_verdict(approved=True)`; `Rejected`+не откатана →
`handle_verdict(approved=False)`. Обработчики `webhooks/plane.py`
**переиспользуются** (async → `asyncio.run` из sync-потока), логика не дублируется.
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по
`repo`+`stage='development'`, видимость на INFO) — defense-in-depth.
**Инварианты:** источник истины — гейт/Plane, не событие; продвижение только через
`advance_stage`; идемпотентность (active-job guard + atomic-claim на создании +
grace + `max_concurrency=1`); never-raise на единицу работы; тишина при
синхронности; restart-safe; kill-switch.
## Альтернативы
- **Флаг подавления нотификаций в `advance_stage`** — отклонён: меняет общий
критический путь. Вместо этого «не вызывать advance_stage на красном гейте».
- **UNIQUE-индекс `tasks.plane_id`** для анти-дубля — отклонён как primary: риск
падения миграции на проде; выбран process-wide `threading.Lock` (single-process
топология). Индекс — задокументированное будущее упрочнение для multi-process.
- **Отдельная стадия/QG реконсиляции** — вне объёма; нарушает «источник истины —
существующий гейт».
- **Реконсиляция analysis по локальным артефактам** — отклонена: автопродвижение
неодобренного человеком BRD.
## Последствия
- Потерянный webhook ≠ молча застрявшая задача; ручной heartbeat-watchdog не нужен;
резервная сетка к ORCH-51 (буфер недоставленных) и ORCH-36 (deploy).
- Плата: фоновый поток + опрос Plane API (митигируется интервалом/фильтром/
per-project); двойная оценка гейта на зелёной задаче; анти-дубль опирается на
single-process-допущение (как и очередь ORCH-1).
- Self-hosting: `reconcile_enabled` — обязательный kill-switch; поэтапный раскат
(`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 как под-гейт ребра
внутри `advance_stage`), adr-0001 (реестр проектов для F-2 per-project), ORCH-5
(events de-dup — защита от дублей; reconciler — обратная защита от потерь),
ORCH-045 (`ci_poll`).

View File

@@ -0,0 +1,77 @@
# ADR-0008: Провенанс staging-образа перед BUILD-ONCE retag в прод (ORCH-058)
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`.
Метка: `arch:major-change`.
> Примечание о нумерации: в `adr/` исторически два файла `adr-0007-*`
> (`executable-self-deploy`, `reconciler`) — пред-существующая коллизия. Этот ADR берёт
> следующий свободный номер **0008**; коллизию 0007 не трогаем (вне объёма ORCH-058).
## Контекст
ORCH-36 (`adr-0007-executable-self-deploy`) сделал стадию `deploy` исполняемой для
self-hosting: Phase B запускает host-хук, который шагом **2b** (BUILD-ONCE) делает
`docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без rebuild** — «прод = ровно тот артефакт,
что прошёл staging». Предпосылка: staging-образ свеж и собран из провалидированного кода.
**Этой гарантии нет.** Конвейер нигде не пересобирает `orchestrator-orchestrator-staging`
из провалидированного коммита; `deploy-staging` лишь гоняет `staging_check.py` против уже
работающего 8501. Инцидент (LESSONS_ORCH-036 п.4): staging-образ не пересобрали → проверка
прошла против старого кода → retag промоутнул СТАРЫЙ образ → прод **молча** откатился на
2-дневный код. Зелёный гейт = ложный позитив. Самый опасный из 4 багов: не падает, а тихо
откатывает инструмент, обслуживающий все проекты.
## Решение
Гарантировать `INV-FRESH`: в прод промоутится только образ, собранный из коммита,
провалидированного `deploy-staging` для данной задачи; иначе fail-fast (`FAILED` → откат на
`development`, БАГ-8), прод не трогается. Достигается **двумя взаимодополняющими слоями**
(defense in depth), только для self-hosting (условность как ORCH-35/36/43):
- **A — пересборка (liveness).** На ребре `deploy-staging → deploy`, ПОСЛЕ merge-gate и ДО
Phase A, детерминированный QG-под-чек `check_staging_image_fresh` пересобирает
`orchestrator-orchestrator-staging` из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, лейбл `org.opencontainers.image.revision`), пересоздаёт 8501
и прогоняет `staging_check`. FAIL → откат на `development`. Так валидируемый и промоутимый
артефакт — один и тот же; гарантирует наличие зелёного пути (нет вечного fail-fast).
- **B — fail-closed guard (safety).** Хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл
`revision` образа `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`).
Несовпадение / пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED.
Делает тихий промоут устаревшего образа структурно невозможным даже при отключённой/
проигравшей гонку A.
**Якорь провалидированного коммита**`git rev-parse HEAD` в worktree ПОСЛЕ merge-gate
(post-rebase tree, который ре-тестирован и сольётся в `main`). Один helper
`validated_revision(repo, branch)` питает и штамп сборки (A), и `EXPECTED_REVISION` (B).
**Условность и kill-switch:** единый `image_freshness_enabled` (вкл/выкл A+B как целое,
чтобы не было «B без A» = вечный fail-fast), `image_freshness_repos` (CSV; пусто →
self-hosting). Все настройки с префиксом `ORCH_`.
### Что НЕ меняется
`STAGE_TRANSITIONS` (набор стадий — под-гейт ребра, не стадия), exit-code хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync,
merge-gate, Phase A/B/C. Схема БД — без миграций (провенанс в лейбле образа, не в БД).
### Что добавляется (сквозное)
- QG `check_staging_image_fresh` в реестре `QG_CHECKS` (+ snapshot-тест), wired через
`_handle_image_freshness` в `stage_engine` (рядом с merge-gate).
- Режим хука `--build-staging` (build из worktree + recreate 8501; STAGING-safe дефолты).
- OCI-лейбл `org.opencontainers.image.revision` в `Dockerfile` (`ARG GIT_SHA`).
- Helpers `validated_revision` / `rebuild_staging_image` в `self_deploy.py` (never-raise).
## Последствия
- Класс «тихого регресса прод» закрыт структурно (B); валидный деплой всегда доходит до
зелёного (A) — устранён ручной bootstrap-разрыв пересборки staging.
- Латентность ребра растёт (build + recreate + повторный staging_check); `staging_check`
гоняется дважды (soft pre-check агента + авторитетный код) — плата за «валидируем =
промоутим».
- Все сборки/recreate — ТОЛЬКО staging (8501); прод (8500) не трогается; `main` не пушится.
Новая под-компонента → `arch:major-change`.
## Связанные ADR
`adr-0007-executable-self-deploy` (BUILD-ONCE, Phase A/B/C), `adr-0006-merge-gate` (образец
edge-под-гейта), `adr-0003-staging-gate` (условность self-hosting), `adr-0005`
(run-as-host-uid). Детальный per-work-item: `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.

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,120 @@
# Lessons Learned — 2026-06-06 (вечер): ORCH-36 + ORCH-53 → прод (эпик ORCH-54)
## Итог
Закрыты две задачи эпика ORCH-54 (автономное внедрение): **ORCH-36** (исполняемый
самодеплой стадии `deploy`) и **ORCH-53** (sweeper/reconciler потерянных webhook).
Обе прошли конвейер через рабочий merge-gate (ORCH-43), но финальный мерж+деплой
потребовал **ручного разрыва bootstrap-цикла** — задача, добавляющая автодеплой, сама
не может задеплоить себя через старую логику. Reconciler доказал себя **в первую секунду
после старта** — разблокировал две реально застрявшие задачи (ORCH-036 и ET-013).
Эпик ORCH-54: **4 из 6 в проде** (ORCH-40 права, ORCH-43 merge-gate, ORCH-36 деплой,
ORCH-53 reconciler). Осталось: ORCH-51 (окно/HA), обкатка полностью автономного деплоя.
---
## 1. 🔴 Bootstrap-парадокс самодеплоя (ORCH-36)
### Симптом
ORCH-36 застряла в петле `deploy → development`:
```
QG check_deploy_status — failed: Deploy log not found (14-deploy-log.md)
→ deployer verdict FAILED, rolled back deploy → development
```
deployer запускался (exit 0), но **не писал** `14-deploy-log.md` → гейт FAILED → откат →
снова deployer → бесконечный цикл (jobs 140→142→143...).
### Корень
Классический bootstrap самохостинга: **новая deploy-логика лежит в ветке, старая работает
в проде**. ORCH-36 учит deployer писать лог по результату РЕАЛЬНОГО деплоя (через хост-хук),
но прод-deployer работает по СТАРОМУ промпту, который для self-репо реального деплоя не делает
и SUCCESS-лог не пишет. Нет лога → FAILED → откат.
### Урок
**Self-репо не может задеплоить сам себя через старую логику.** Нужен разовый ручной разрыв
цикла: домержить + задеплоить руками ОДИН раз, дальше конвейер катит своей же новой логикой.
Тот же паттерн был у ORCH-40/43. Это структурное свойство любой задачи, меняющей
deploy/merge-механику самого оркестратора — закладывать ручной bootstrap-шаг в план.
---
## 2. 🔴 Merge-конфликт при последовательном ручном мерже двух задач
### Симптом
PR #56 (ORCH-53) смержен первым — чисто. PR #55 (ORCH-36) сразу после → **CONFLICT 409**:
`.env.example`, `CHANGELOG.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`,
**`src/config.py`**.
### Корень
После мержа PR #56 `main` ушёл вперёд → PR #55 валидировался против СТАРОГО main (точки
ответвления), а мержится в НОВЫЙ. Это ровно класс «main ушёл вперёд», который чинит
merge-gate (ORCH-43) — но при РУЧНОМ мерже через Gitea API merge-gate не участвует.
### Решение
- **merge main→ветку, НЕ rebase.** Rebase 9 коммитов = 9 потенциальных конфликт-разборов;
один merge-коммит = ОДИН разбор. Быстрее и безопаснее для большого набора коммитов.
- Конфликт в `src/config.py` был чисто **аддитивный**: ветка ORCH-36 добавляла блок
`self_deploy_*` настроек, main (ORCH-53) — блок `reconcile_*`. Нужны **ОБА** блока →
склеить, убрав только git-маркеры (`<<<<<<<`/`=======`/`>>>>>>>`). Обязательно после —
`python3 -c 'import ast; ast.parse(...)'` для проверки синтаксиса.
- docs/.env/CHANGELOG конфликты — тоже аддитивные (обе стороны добавляют строки) → union.
### Грабли
⚠️ `grep -rE '^(<<<<<<<|=======|>>>>>>>)'` по `docs/work-items/*/13-test-report.md` даёт
**ЛОЖНЫЕ срабатывания** — там `=======` это markdown-разделители таблиц/секций, не
git-конфликты. Проверять реальные конфликтные файлы поимённо, не доверять глобальному grep.
---
## 3. Review-гейт поймал 2 реальных P1 ДО прода (ORCH-36)
reviewer завернул первую версию (`verdict: REQUEST_CHANGES`), конвейер сам откатил
dev→review→fix→APPROVED. Два P1:
1. **sentinel-маркеры self-deploy (`initiated`/`result`/`approve-requested`) не чистились на
rollback** → при возврате задачи человек ставит Approved, а устаревший маркер ломает фазу B.
2. **нет `.env.example` для новых флагов** + процедуры «approve→деплой» в `INFRA.md`.
Урок: merge-gate + review отрабатывают как задумано — брак не уходит в прод автономно.
Это и есть ценность эпика: система фильтрует сама.
---
## 4. 🔥 Reconciler доказал себя мгновенно (ORCH-53)
В первую секунду после рестарта прода (21:24 UTC):
```
reconciler: ORCH-036 development разблокирована (потерян webhook)
reconciler: ET-013 development разблокирована (потерян webhook)
```
Sweeper нашёл и разблокировал ДВЕ реально застрявшие задачи — включая саму ORCH-036 из
bootstrap-петли, и старое зависание ET-013 (enduro-trails). Ручной heartbeat-watchdog,
который раньше держал Стрим, **больше не нужен** — система чинит застревания сама.
---
## 5. Операционные мелочи (закрепить)
- **Заголовки ORCH-задач ≤80 символов.** QG-0 (`check title length`) заворачивает старт
конвейера, если длиннее. ORCH-53 был 83 символа → завернул на старте → подрезали до 71.
- **Developer-таймаут 1800с (30 мин) мал для мясных задач.** 1-й заход developer'а ORCH-36
(деплой-хук + Telegram-кнопка + callback) упёрся в лимит → SIGKILL (exit -9). Спас
resilience-ретрай (ORCH-1b): attempt 2, наработки в worktree между попытками сохранились.
Если упирается систематически — поднять `agent_timeout_seconds` (override per-agent) или
дробить задачу.
- **Время хоста ≠ UTC.** Файлы worktree датируются по мск (+3), БД/системное — UTC. Не баг,
но путает сверки `etime`/`updated_at`/`finished_at`. Сверять по одному источнику.
- **Gitea merge auth:** заголовок строго `Authorization: token <ORCH_GITEA_TOKEN>` (формат
`token `, буквально). НЕ маскировать токен плейсхолдером `***` → иначе 401.
POST `/repos/admin/orchestrator/pulls/{N}/merge`, body `{"Do":"merge"}`.
- **approve прод-деплоя 8500 = Telegram-кнопка** (решение Owner), флаг
`DEPLOY_REQUIRE_MANUAL_APPROVE=true` по дефолту.
- **max_concurrency=1 оставлен сознательно** (решение Owner): одна БД/очередь на все
проекты, последовательное выполнение надёжнее. НЕ поднимать без явного запроса.
---
## Состояние прода после деплоя (21:24 UTC, main `1ff8d85`)
- `src/self_deploy.py` — в проде (исполняемый деплой, 3 фазы A/B/C)
- `src/reconciler.py` — в проде (фоновый sweeper, уже разблокировал 2 задачи)
- uid 1000, health `{"status":"ok"}`, preflight True (Claude Code 2.1.142)
- Деплой-скрипт с авто-rollback: исходник в workspace `temp/deploy_36_53.sh`

View File

@@ -0,0 +1,78 @@
# Lessons Learned — 2026-06-07 (утро): ORCH-36 self-deploy bootstrap — каскад неготовности инфры
## Итог
ORCH-36 (исполняемый самодеплой стадии `deploy`) **замкнулась в проде** — конвейер
впервые задеплоил сам себя по полному циклу Phase A→B→C (approve → детачед ssh-хук →
finalizer). Но путь до Done вскрыл **четыре слоя неготовности инфраструктуры**, каждый из
которых требовал ручного bootstrap-разрыва: задача про автодеплой не может задеплоить
сама себя, пока её же механизм + инфра не в проде.
Эпик ORCH-54: **4/6 в проде** (ORCH-40 права, ORCH-43 merge-gate, ORCH-36 самодеплой,
ORCH-53 reconciler). Конвейер автономен: мержит → катит в прод → чинит застрявшее.
---
## Каскад из 4 инфра-багов (все вскрылись только при РЕАЛЬНОМ деплое)
### 1. 🔴 uid 1000 без записи в `/etc/passwd` → ssh/whoami падают
**Симптом:** `self-deploy initiate failed: ssh launch failed (rc=255): No user exists for
uid 1000`. **Корень:** регрессия ORCH-40 — compose запускает контейнер под `1000:1000`,
но базовый образ `python:3.12-slim` не имеет passwd-записи для 1000. SSH-клиент (и
`whoami`, `getpwuid()`) отказываются стартовать без валидного юзера.
**Фикс:** в `Dockerfile``groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d
/home/slin -s /bin/bash slin`. Rebuild + recreate. Коммит `64e031a`.
**Урок:** при переводе контейнера на non-root uid (ORCH-40) ОБЯЗАТЕЛЬНО создавать passwd-
запись в образе, иначе ssh/git/любой инструмент с getpwuid() ломается. Проверять
`docker exec <c> whoami` после смены uid.
### 2. 🔴 env-префикс: `DEPLOY_*` vs `ORCH_DEPLOY_*` (pydantic не видит)
**Симптом:** `ssh: Could not resolve hostname : No address associated with hostname`
host пустой, хотя в compose `DEPLOY_SSH_HOST=127.0.0.1` задан. **Корень:** `Settings`
имеет `env_prefix = "ORCH_"` → читает ТОЛЬКО `ORCH_DEPLOY_SSH_HOST`. Старые
`DEPLOY_*` (без префикса) предназначались легаси enduro-деплоеру (читает через
`os.environ` напрямую) и pydantic их игнорирует → дефолт `host=""`. Доп: `DEPLOY_HOOK_SCRIPT`
указывал на `enduro-deploy-hook.sh`, не на orchestrator-хук.
**Фикс:** в `docker-compose.yml` добавлены `ORCH_DEPLOY_SSH_USER/HOST`,
`ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh`,
`ORCH_DEPLOY_HOST_REPO_PATH` (легаси `DEPLOY_*` оставлены для enduro). Коммит `115519e`.
**Урок:** все настройки, читаемые через pydantic Settings, ДОЛЖНЫ иметь префикс `ORCH_`.
Проверять резолв: `docker exec <c> python3 -c 'from src.config import settings; print(settings.deploy_ssh_host)'`.
### 3. 🔴 `/var/log/orchestrator` принадлежит root → хук падает на tee
**Симптом:** `tee: /var/log/orchestrator/deploy-hook.log: Permission denied`, хук exit 1.
**Корень:** лог-директория `root:root`, а хук бежит под `slin`. **Фикс:** `chown -R
slin:slin /var/log/orchestrator` на хосте.
**Урок:** все пути, в которые пишет хост-хук (логи, sentinel, prev-image), должны быть
writable юзером, под которым ssh-сессия. Заложить создание/chown в provisioning хоста.
### 4. 🔴🔴 BUILD-ONCE retag берёт УСТАРЕВШИЙ staging-образ → катит регресс (ВАЖНО)
**Симптом:** деплой «зелёный» (result=0, health ok), но прод откатился на код 2-дневной
давности — пропал `deploy-finalizer` (`Unknown agent: deploy-finalizer`), задача не
закрылась. **Корень:** хук делает `BUILD-ONCE: retag orchestrator-orchestrator-staging →
orchestrator-orchestrator` (без rebuild, by design ORCH-36 BR-6). Дизайн предполагал
«staging-образ = свежий, провалидированный». В РЕАЛЬНОСТИ `orchestrator-orchestrator-staging`
никто не пересобрал из нового main → retag катил в прод СТАРЫЙ образ → бесконечная петля:
каждый Phase B возвращал прод в прошлое, finalizer (новый код) исчезал, Phase C не мог
закрыть задачу.
**Фикс (ручной разрыв):** пересобрать `orchestrator-orchestrator-staging` из актуального
main ПЕРЕД retag → тогда хук катит свежий код. После этого Phase C отработал: result=0 →
SUCCESS → `deploy → done`.
**Урок / ТЕХДОЛГ:** retag-стратегия BUILD-ONCE предполагает гарантию свежести staging-
образа, которой НЕТ. Нужна отдельная задача: либо staging-деплой пересобирает образ из
текущего main перед валидацией, либо deploy-хук проверяет, что staging-образ собран из
HEAD main (по labels/sha), иначе fail-fast. Сейчас «зелёный» деплой может молча катить
регресс. **Это самый опасный из четырёх — он не падает, а тихо откатывает прод.**
---
## Сквозной урок: bootstrap самохостинга
Любая задача, меняющая deploy/merge-механику САМОГО оркестратора, упирается в парадокс:
её механизм не работает, пока не в проде, а в прод его можно влить только старым
механизмом. Каждый слой (код → права → env → образ) вскрывается ТОЛЬКО при первом
реальном прогоне. Закладывать в план таких задач **ручной bootstrap-чеклист** и гонять
**реальный** деплой в staging-петле до мержа, а не только бумажные гейты.
## Прод после (main `115519e`+, образ 2026-06-07 09:47)
- self_deploy.py + reconciler.py в проде, finalizer зарегистрирован (grep=5)
- uid 1000 = slin (passwd ok), ssh slin@127.0.0.1 работает, /var/log/orchestrator writable
- ORCH-36 task 43 → done, Plane → Done

View File

@@ -9,6 +9,7 @@
1. **Захват текущего образа** — до рестарта записывает ID образа работающего контейнера в `$PREV_IMAGE_FILE` (best-effort, не падает если сервис не запущен).
2. **git pull** — обновляет код репозитория.
2b. **Build-once retag** (ORCH-036, BR-6) — если задан `$SOURCE_IMAGE`, хук ретегает его на `$TARGET_IMAGE` (`docker tag $SOURCE_IMAGE $TARGET_IMAGE`) и поднимает контейнер на этом образе через `up -d --no-build`. Это деплой РОВНО того образа, что прошёл staging, **без `docker build`**. Если `$SOURCE_IMAGE` не задан (дефолт) — шаг пропускается (обратная совместимость).
- **Fail-closed провенанс-guard** (ORCH-058, Strategy B) — ПЕРЕД `docker tag`, если задан `$EXPECTED_REVISION`, хук сверяет OCI-лейбл `org.opencontainers.image.revision` у `$SOURCE_IMAGE` с `$EXPECTED_REVISION`. Несовпадение / пустой лейбл (`<no value>`) / ошибка inspect → лог + `exit 1` (FAILED → авто-rollback), **прод не трогается**. Не задан `$EXPECTED_REVISION` (дефолт) → проверка пропускается (обратная совместимость для не-self репозиториев).
3. **Рестарт контейнера**`docker compose --profile $COMPOSE_PROFILE up -d --no-build $TARGET_SERVICE`.
4. **Health-цикл** — 10 попыток × 6с = до 60с. Критерий: HTTP 200 + тело содержит `"status":"ok"`.
- **Успех** → `exit 0`, лог "Deploy SUCCESS".
@@ -17,6 +18,17 @@
- Если восстановился → `exit 1` (деплой провалился, откат успешен).
- Если и откат не помог → `exit 2` (критично).
### Режим `--build-staging` (ORCH-058, Strategy A)
Пересобирает **staging-образ** из провалидированного коммита и пересоздаёт 8501, чтобы артефакт, который мы валидируем, был РОВНО тем, что позже build-once ретегается в прод (инвариант `INV-FRESH`). Собирает/пересоздаёт **только staging (8501)** — никогда прод (8500).
1. `docker build --build-arg GIT_SHA=$GIT_SHA -t $TARGET_IMAGE $BUILD_CONTEXT` — пересборка из host-worktree валидированного коммита; `GIT_SHA` штампуется в OCI-лейбл `org.opencontainers.image.revision`.
2. `docker compose [--profile $COMPOSE_PROFILE] up -d --no-build $TARGET_SERVICE` — пересоздание staging на свежем образе.
3. Health-цикл 10×6с. Провал сборки/health → `exit 1`.
4. **`staging_check` против СВЕЖЕГО образа** (Strategy A, шаг 3 — ADR-001, AC-4) — после health хук запускает `docker exec $STAGING_CONTAINER python3 $STAGING_CHECK_PATH --base-url http://localhost:$TARGET_PORT --mode $STAGING_CHECK_MODE` (дефолт `--mode stub`, без LLM-трат). Запуск **внутри** staging-контейнера канонический (ORCH-048): suite читает реестр из собственного env контейнера, а `staging_check.py` берётся из bind-mount (`/repos/orchestrator/scripts/...`, не из образа). Это ровно тот артефакт, что позже build-once ретегается в прод → валидируем то, что промоутим (AC-4). PASS → `exit 0`; любой не-ноль (FAIL чека или safety-abort `ORCH_STAGING≠true`) → `exit 1`.
Запускается оркестратором на ребре `deploy-staging → deploy` (QG-под-чек `check_staging_image_fresh``rebuild_staging_image` пробрасывает явный staging-таргет, см. `INFRA.md`). Тот же контракт кодов выхода (0 = здоров **и** staging_check PASS).
### Режим `--rollback`
Вручную откатывает сервис на предыдущий образ из `$PREV_IMAGE_FILE`.
@@ -31,6 +43,12 @@
| `COMPOSE_PROFILE`| `staging` | Docker compose profile (пусто = без профиля) |
| `PREV_IMAGE_FILE`| `$REPO/.deploy-prev-image-staging`| Файл для сохранения предыдущего образа |
| `SOURCE_IMAGE` | _(unset)_ | Build-once (ORCH-036): провалидированный образ для retag на `$TARGET_IMAGE` перед рестартом (без rebuild). Не задан → шаг пропущен. |
| `EXPECTED_REVISION` | _(unset)_ | Build-once (ORCH-058, Strategy B): ожидаемый git-SHA `$SOURCE_IMAGE` (лейбл `org.opencontainers.image.revision`). Задан → fail-closed guard перед `docker tag`. Не задан → проверка пропущена. |
| `GIT_SHA` | _(unset)_ | `--build-staging` (ORCH-058, Strategy A): коммит, штампуемый в OCI-лейбл `revision` при пересборке staging-образа. |
| `BUILD_CONTEXT` | `$REPO` | `--build-staging`: docker build context (host-worktree валидированного коммита). |
| `STAGING_CONTAINER` | `$TARGET_SERVICE` (`orchestrator-staging`) | `--build-staging` (ORCH-058): контейнер, внутри которого `docker exec` запускает `staging_check`. |
| `STAGING_CHECK_PATH` | `/repos/orchestrator/scripts/staging_check.py` | `--build-staging` (ORCH-058): путь к `staging_check.py` внутри контейнера (bind-mount, не образ). |
| `STAGING_CHECK_MODE` | `stub` | `--build-staging` (ORCH-058): режим `staging_check` (`stub` — быстро, без LLM; `full-real` — дожидается аналитика). |
| `LOG` | `/var/log/orchestrator/deploy-hook.log` | Лог-файл (fallback: `$REPO/deploy-hook.log`) |
> ⚠️ **Дефолт — всегда STAGING**. Прод активируется только явным переопределением env.

View File

@@ -83,6 +83,15 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл
| `ORCH_DEPLOY_HOOK_SCRIPT` / `_HOST_REPO_PATH` | путь к хук-скрипту (отн. репо) и чекаут orchestrator на хосте |
| `ORCH_DEPLOY_PROD_SOURCE_IMAGE` | staging-образ для build-once retag на прод-тег (без rebuild) |
| `ORCH_DEPLOY_PROD_TARGET_SERVICE` / `_TARGET_PORT` / `_TARGET_IMAGE` / `_COMPOSE_PROFILE` / `_PREV_IMAGE_FILE` | прод-цель хука + снапшот для авто-rollback |
| `ORCH_IMAGE_FRESHNESS_ENABLED` | ORCH-058 единый kill-switch провенанса staging-образа (A+B как целое); дефолт `true`, false → legacy build-once без проверки свежести |
| `ORCH_IMAGE_FRESHNESS_REPOS` | CSV репозиториев с реальным гейтом свежести; пусто → только self-hosting `orchestrator` |
| `ORCH_RECONCILE_ENABLED` | kill-switch sweeper потерянных webhook (ORCH-053); дефолт `true`. **При инциденте/раскатке**`false` глушит весь фоновый reconciler |
| `ORCH_RECONCILE_PLANE_ENABLED` | отдельный флаг F-2 (опрос Plane API); `false` гасит только plane-ветку, F-1 продолжает работать; дефолт `true` |
| `ORCH_RECONCILE_INTERVAL_S` | период фонового прохода reconciler, сек; дефолт `120` |
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | порог «застряла» по `tasks.updated_at`, сек; дефолт `600` |
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | per-stage пороги, напр. `{"development":300}`; невалидный JSON → дефолт |
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | слать Telegram при разблокировке застрявшей задачи; дефолт `true` |
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
@@ -124,6 +133,7 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл
**Страховки:**
- Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op.
- **Свежесть staging-образа (ORCH-058):** на ребре `deploy-staging → deploy` (ПОСЛЕ merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` пересобирает staging-образ из валидированного коммита и пересоздаёт 8501 (Strategy A), а хук перед build-once retag fail-closed сверяет OCI-лейбл `revision` с `EXPECTED_REVISION` (Strategy B). Гарантирует: в прод промоутится РОВНО провалидированный артефакт (инцидент LESSONS_ORCH-036 п.4 — тихий промоут устаревшего образа). Сборки/recreate — ТОЛЬКО staging (8501); FAIL → откат на `development`. Условный: реален только для self-hosting.
**Правила для агентов при задачах ORCH:**
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.

View File

@@ -75,6 +75,27 @@ completely invisible to commands that do not pass `--profile staging`.
docker logs -f orchestrator-staging
```
## Staging-образ как источник прод-артефакта (ORCH-058)
Прод-деплой орка — **build-once**: хук ретегает провалидированный staging-образ
(`orchestrator-orchestrator-staging`) на прод-тег **без rebuild** (ORCH-036). Чтобы
в прод не попал устаревший образ (инцидент LESSONS_ORCH-036 п.4), ORCH-058 гарантирует
свежесть staging-образа **двумя слоями** (только self-hosting):
- **A — пересборка staging (liveness):** на ребре `deploy-staging → deploy` (ПОСЛЕ
merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` через хук
`--build-staging` пересобирает staging-образ из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, OCI-лейбл `org.opencontainers.image.revision`) и
пересоздаёт 8501. Так валидируем РОВНО тот артефакт, что промоутится в прод.
FAIL → откат на `development`. Сборки/recreate — **только staging (8501)**.
- **B — fail-closed guard (safety):** прод-хук перед `docker tag` сверяет лейбл
`revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает оркестратор);
несовпадение / пустой лейбл / ошибка inspect → `exit 1`, прод не трогается.
Kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` включает A+B **как целое**; область —
`ORCH_IMAGE_FRESHNESS_REPOS` (пусто → только `orchestrator`). Детали — `DEPLOY_HOOK.md`,
`docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
## Roadmap
| Task | Description |

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

@@ -1,6 +1,6 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T21:06:37Z
timestamp: 2026-06-06T21:47:48Z
base_url: http://localhost:8501
---

View File

@@ -0,0 +1,7 @@
# Business Request: Sweeper потерянных webhook: реконсиляция застрявших стадий (stuck-task)
Work Item ID: ORCH-053
## Description
TBD

View File

@@ -0,0 +1,128 @@
# BRD — ORCH-053: Sweeper потерянных webhook (реконсиляция застрявших стадий)
Work Item ID: ORCH-053
Стадия: analysis → (architecture)
Тип: надёжность конвейера (проектирование + реализация). Self-hosting (ORCH).
## 1. Проблема (бизнес-контекст)
Продвижение задач между стадиями конвейера завязано **исключительно** на входящие
webhook-события:
- **Plane** (`work_item.updated` → статус In Progress / Approved / Rejected) — единственный
триггер старта задачи, advance и rollback (`src/webhooks/plane.py`).
- **Gitea** (CI-status `success`/`failure`, push, PR reviewed/merged) — триггер
development→review, architecture→development, review→testing, deploy→done
(`src/webhooks/gitea.py`).
Если входящее событие **потеряно** (502 на падающем/ребилдящемся инстансе, Plane/Gitea
не повторяют доставку, сетевой сбой, sha→branch не разрезолвился, вебхук был временно
выключен) — статус в источнике истины (Plane / зелёный CI) уже изменился, **а задача в
оркестраторе не сдвинулась**. Задача висит молча, без какого-либо механизма восстановления.
**Живой инцидент (ORCH-044, 06.06):** dev-агент отработал (exit 0, CI позеленел), но
Gitea webhook о CI-success не продвинул задачу (не дошёл / не сматчился sha→branch).
Задача висела бы на `development` молча навсегда — спасли только ручным дёрганьем гейта
`check_ci_green`. Это **системная дыра**, а не разовый сбой; сейчас её ловит ручной
heartbeat-watchdog Стрима (костыль).
### Что уже есть и почему недостаточно
| Механизм | Что покрывает | Почему не закрывает дыру |
|----------|---------------|--------------------------|
| `requeue_running_jobs()` (startup) | зависшие **jobs** при рестарте | про jobs, не про застрявший **stage-переход** |
| orphan-recovery (`main.py`) | `agent_runs` без `finished_at` | job-уровень, не stage |
| ORCH-5 events de-dup (`delivery_id`) | защита от **дублей** webhook | обратной защиты от **потери** нет |
| ORCH-045 `ci_poll` в `check_ci_green` | поллит CI 12×10с | только **если гейт уже вызван** webhook'ом; не пришёл webhook → гейт не вызывается |
Общий принцип всех существующих механизмов — restart-safe resilience на уровне jobs.
**Нет ни одного механизма, реконсилирующего рассинхрон «источник истины ≠ стадия задачи».**
## 2. Цель
Задача **не должна застревать молча** из-за потерянного входящего события. Ввести
фоновый периодический **sweeper / reconciler**, который сам находит «зависшие» задачи
и доигрывает пропущенный переход — через **те же штатные гейты и обработчики**, что и
webhook (никакой параллельной логики продвижения). Убрать необходимость в ручном
heartbeat-watchdog.
## 3. Заинтересованные стороны
- **Owner / Стрим (Слава)** — перестаёт ловить зависания вручную.
- **Все проекты на инстансе** (enduro-trails + orchestrator) — конвейер не встаёт молча.
- **Self-hosting (ORCH)** — особенно при ребилде прода (ORCH-51): вебхуки, прилетевшие
на падающий инстанс, подбираются реконсиляцией после старта.
## 4. Объём (Scope)
В объёме — **две взаимодополняющие ветки реконсиляции** (обе обязательны):
### F-1. Gate-side sweeper (реконсиляция застрявшей стадии по локальной БД)
Периодический проход по таблице `tasks`: найти задачи, у которых
(а) `stage != done`, (б) нет активных job'ов в очереди, (в) с момента `updated_at`
прошло больше **per-stage порога** → пере-проверить QG текущей стадии и, если passed —
продвинуть **штатным путём** (`stage_engine.advance_stage(..., finished_agent=None)`,
тот же путь, что использует webhook). Закрывает потерю Gitea CI/PR-вебхуков (ORCH-044).
### F-2. Plane-side reconciler (реконсиляция потерянного Plane status-webhook)
Периодический опрос Plane API по проектам реестра (`projects.py`): issues в статусах,
требующих действия (In Progress / Approved / Rejected). Сверить с локальной `tasks` и
доиграть **через существующие обработчики `webhooks/plane.py`**:
- **In Progress + нет задачи в БД** → создать+запустить (`handle_status_start`/`start_pipeline`);
- **Approved + стадия не сдвинута** → advance (`handle_verdict(approved=True)`);
- **Rejected + не откатана** → rollback (`handle_verdict(approved=False)`).
### F-3. Усиление sha→branch резолва в Gitea-вебхуке
В `handle_ci_status` добавить надёжный fallback (поиск task по БД), чтобы исходный
webhook реже терялся из-за неразрезолвленного branch. Sweeper работает от задачи
(repo+branch известны из БД) и обходит эту хрупкость по определению.
### F-4. Наблюдаемость
Лог (и опц. Telegram) каждый раз, когда sweeper **разблокировал** застрявшую задачу —
чтобы видеть частоту срабатывания дыры (метрика потерянных webhook). Опц. вывод
счётчика в `/queue` или `/reconcile`. Не спамить, когда всё синхронно.
### Вне объёма
- Буфер недоставленных webhook (это ORCH-51; sweeper — резервная сетка к нему).
- Изменение состава стадий/гейтов (`STAGE_TRANSITIONS`, `QG_CHECKS`).
- Изменение логики самих гейтов и обработчиков (только переиспользование).
- Новый исполняемый деплой (ORCH-36).
## 5. Ключевые требования (бизнес-уровень)
1. **Источник истины — гейт/Plane, а не событие.** Sweeper дёргает ровно те же функции
продвижения, что и webhook. Параллельной логики продвижения быть не должно.
2. **Идемпотентность (критично).** Задержавшийся или дублированный webhook + sweeper
НЕ создают двойную задачу / двойной запуск / двойной advance. Тот же guard, что у
webhook: нет активного job + стадия совпадает + atomic claim как в `queue_worker`.
3. **Безопасность активной работы.** Sweeper НЕ трогает задачи с активными
(`queued`/`running`) job'ами — они легитимно в работе, не потеряны.
4. **Per-stage grace.** Разные стадии имеют разное нормальное время (analysis ~815 мин
vs deploy). Порог застревания настраивается, чтобы не дёргать гейт у задачи, где агент
законно работает.
5. **Restart-safe.** Sweeper — фоновый поток, стартует с приложением, переживает рестарт
(как `queue_worker`). Без потери состояния.
6. **Self-hosting safety.** Sweeper не должен ронять/рестартить прод-контейнер; kill-switch
в конфиге для поэтапного раската и аварийного отключения.
7. **Без шума.** Когда всё синхронно — никаких действий и нотификаций.
8. **Документация = golden source.** README/architecture, ADR, CHANGELOG обновляются в
том же PR.
## 6. Эффект
- Потерянный webhook больше не = молча застрявшая задача.
- Ручной heartbeat-watchdog Стрима больше не нужен для ловли зависаний (AC-5 в эпике).
- Резервная сетка к ORCH-51 при ребилде прода.
## 7. Связи
- **Дополняет ORCH-51** (потеря webhook при рестарте — буфер; sweeper — реконсиляция).
- **Дополняет ORCH-36** (если deploy-webhook потеряется — sweeper добьёт deploy→done).
- **ORCH-1b** — та же философия resilience: транзиентный сбой не убивает задачу.
- Эпик: звено **ORCH-54** (автономное внедрение). Параллельна ORCH-36 (разные файлы),
но `max_concurrency=1` → встанет в очередь.
## 8. Риски (кратко; подробно — 10-tech-risks архитектора)
- **Гонка sweeper ↔ живой webhook** → двойной запуск. Митигируется atomic claim +
active-job guard + grace-период (не конкурировать с задержавшимся webhook).
- **Spam нотификаций** при персистентно красном гейте на каждом тике. Митигируется:
действие/нотификация только на изменении состояния (advance), не на каждый тик.
- **Нагрузка на Plane API** при опросе каждые N сек. Митигируется интервалом + фильтром
по статусам + per-project.
- **Self-hosting:** sweeper правит инструмент, обслуживающий и другие проекты. Kill-switch
обязателен.

View File

@@ -0,0 +1,170 @@
# ТЗ — ORCH-053: Sweeper потерянных webhook (реконсиляция застрявших стадий)
Work Item ID: ORCH-053
Базовая ветка: `feature/ORCH-053-sweeper-webhook-stuck-task`
> Это ТЗ фиксирует **конкретные изменения кода/конфига/доки**. Архитектурные развилки
> (потокобезопасность, точная схема дампинга нотификаций, способ вызова async-обработчиков
> из sync-потока) фиксирует архитектор в `06-adr/`. Если ТЗ окажется негодным — возврат в
> Анализ (не комментировать задним числом).
## 0. Живая разведка ПЕРЕД реализацией (обязательна)
Перед кодом разработчик обязан вживую проверить (как сейчас webhook продвигает стадию):
- `src/webhooks/gitea.py::handle_ci_status` (success-ветка ~стр.199217) и `handle_pr`;
- `src/webhooks/plane.py::handle_issue_updated / handle_status_start / handle_verdict / start_pipeline`;
- `src/stage_engine.py::advance_stage` (унифицированный путь, `finished_agent=None` = webhook-путь);
- `src/queue_worker.py` (образец фонового daemon-потока + `threading.Event` + atomic claim);
- `src/db.py::has_active_job_for_task / claim_next_job / update_task_stage` (`updated_at`).
## 1. Задействованные модули `src/`
| Модуль | Изменение |
|--------|-----------|
| `src/reconciler.py` | **НОВЫЙ.** Фоновый sweeper/reconciler (класс + module-singleton, паттерн `queue_worker`). Обе ветки F-1 (gate-side) и F-2 (plane-side). |
| `src/config.py` | Новые настройки `reconcile_*` (интервал, kill-switch, per-stage grace, plane-poll flag). |
| `src/main.py` | Старт/стоп reconciler в `lifespan` (после `worker.start()` / перед `worker.stop()`). |
| `src/stage_engine.py` | Тонкий хелпер `advance_if_gate_passed(...)` (или `reconcile_advance`) — обёртка над `advance_stage(..., finished_agent=None)`, **подавляющая повторный спам нотификаций** при провале гейта (продвижение — переиспользуется как есть). |
| `src/plane_sync.py` | НОВЫЙ хелпер `list_issues_by_state(project_id, state_uuids) -> list[dict]` (GET issues с пагинацией, фильтр по state). Используется F-2. |
| `src/webhooks/gitea.py` | F-3: усилить sha→branch резолв в `handle_ci_status` (fallback на БД-поиск task), логировать неразрезолв на уровне INFO (видимость). |
| `src/webhooks/plane.py` | F-2 переиспользует `handle_issue_updated` / `handle_status_start` / `handle_verdict` **без дублирования** логики (возможно, лёгкий рефактор для вызова из reconciler). |
| `src/main.py` (API) | F-4 (опц.): расширить `/queue` блоком reconcile-метрик или добавить `GET /reconcile`. |
## 2. F-1 — Gate-side sweeper (реконсиляция по локальной БД)
### Алгоритм одного прохода (`reconcile_gate_once()`)
```
для каждой task где stage NOT IN ('done',) :
если has_active_job_for_task(task.id): continue # в работе — не трогаем
если get_qg_for_stage(task.stage) is None: continue # created/done — нет гейта
grace = grace_for_stage(task.stage)
если age(task.updated_at) < grace: continue # ещё не «застряла»
# источник истины — гейт; путь продвижения — штатный
advance_if_gate_passed(task.id, task.stage, task.repo, task.work_item_id, task.branch)
```
- **Продвижение** идёт через `stage_engine.advance_stage(task_id, stage, repo, work_item_id,
branch, finished_agent=None)` — это **тот же** путь, которым пользуется Plane Approved-webhook
(`webhooks/plane._try_advance_stage`). Никакой параллельной логики advance.
- Для `development` → `advance_stage` прогонит `check_ci_green`; passed → `review` + enqueue
`reviewer`. Для `review` → `check_reviewer_verdict` (канонический гейт стадии из
`STAGE_TRANSITIONS`, читает `verdict:` из `12-review.md`). Для `testing` → `check_tests_passed`.
Для `deploy` → `check_deploy_status`. Для `deploy-staging` → `check_staging_status`
(+ merge-gate sub-gate отрабатывает внутри `advance_stage` как обычно).
- **Стадия `analysis`** (gQG `check_analysis_approved`): это **человеческий** гейт. В
`advance_stage` при `finished_agent=None` он трактуется как `approved-via-status` и
продвинет задачу — чего при потере именно **Approved**-webhka мы и хотим **только** если
Plane реально в статусе Approved. Поэтому **F-1 НЕ реконсилирует `analysis`** (advance
для analysis отдаётся F-2, которая сверяется с реальным статусом Plane). Архитектор
фиксирует это решение в ADR (защита от ложного продвижения неодобренного BRD).
### Подавление спама нотификаций (`advance_if_gate_passed`)
- Если гейт **passed** → `advance_stage` продвигает и шлёт штатные нотификации advance.
- Если гейт **failed** → НЕ повторять `notify_qg_failure`/`plane_notify_qg` на каждом тике.
Хелпер вызывает `advance_stage` так, чтобы при провале была **тишина** (лог `INFO`/`DEBUG`),
либо реализует продвижение, минуя ветку нотификации провала. Точную форму (флаг в
`advance_stage` vs отдельный путь оценки гейта) выбирает архитектор; контракт:
**на застрявшей-но-красной задаче sweeper не спамит**.
### Защита от гонки
- `has_active_job_for_task` + `update_task_stage` обновляет `updated_at` → следующий тик
увидит свежий `updated_at` и не сработает повторно.
- Если в момент тика прилетел живой webhook и поставил job — sweeper увидит активный job и
пропустит задачу.
- `max_concurrency=1`: новый enqueued job встанет в общую очередь (без двойного запуска).
## 3. F-2 — Plane-side reconciler (опрос Plane API)
### Алгоритм одного прохода (`reconcile_plane_once()`)
```
для каждого проекта p в projects.PROJECTS:
states = get_project_states(p.plane_project_id)
for issue in list_issues_by_state(p.plane_project_id,
[states['in_progress'], states['approved'], states['rejected']]):
task = get_task_by_plane_id(issue.id)
new_state = issue.state
# идемпотентность: пропускаем, если есть активный job (живой webhook вот-вот придёт/в работе)
если task and has_active_job_for_task(task.id): continue
# доигрываем потерянный переход ЧЕРЕЗ существующие обработчики plane.py
if new_state == in_progress and task is None: -> handle_status_start(issue_data, p.plane_project_id)
elif new_state == approved and task and stage не сдвинут: -> handle_verdict(issue_data, ..., approved=True)
elif new_state == rejected and task and не откатана: -> handle_verdict(issue_data, ..., approved=False)
else: continue # всё синхронно — тишина
```
- **Переиспользовать** `handle_issue_updated`/`handle_status_start`/`handle_verdict` из
`webhooks/plane.py`. Они `async` → reconciler (sync-поток) вызывает их через
`asyncio.run(...)` либо собственный event loop. Способ — на усмотрение архитектора;
**дублировать логику запрещено**.
- `issue_data` собирается в форму, ожидаемую обработчиками (`{"id", "state": {"id":...},
"project", "name", "description_stripped"}`). Недостающие поля (name/description)
обработчики сами дотягивают через `fetch_issue_fields` (как сейчас для status-only вебхука).
- **Grace для F-2:** не реагировать на issue, чей статус сменился совсем недавно (вебхук мог
просто задержаться). Источник «давности» — поле времени из Plane (`updated_at`) и/или
локальный grace по `tasks.updated_at`. Архитектор фиксирует точный критерий «потерян, а не
задержан».
- **Идемпотентность создания (In Progress без задачи):** `start_pipeline` уже защищён
(`handle_status_start` создаёт только если `get_task_by_plane_id` пуст). Гонка sweeper↔webhook
на создании: оба пройдут проверку «нет задачи» одновременно → возможен дубль. Требование:
использовать тот же claim-механизм / уникальность (как `ensure_unique_work_item_id` +
проверка существования под защитой). Архитектор обязан описать atomic-claim на создании в ADR.
### `list_issues_by_state` (новый в `plane_sync.py`)
- `GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{pid}/issues/` с фильтром по state
(через query-параметр Plane, либо постфильтрация результата по `issue.state`).
- Пагинация (`results` + cursor/next) — обойти все страницы.
- Never-raise: при ошибке API/сети → `[]` + лог `warning` (Plane outage деградирует мягко,
не роняет тик).
## 4. F-3 — Усиление sha→branch резолва (`webhooks/gitea.py::handle_ci_status`)
- Текущая цепочка: `branches[0].name` → `git branch -r --contains <sha>`. Добавить
fallback **на БД**: если branch не определён, найти task по `repo` среди активных
(`stage='development'`) и, при однозначности, использовать её branch; иначе — оставить
неразрезолвленным.
- Заменить `logger.debug("could not determine branch...")` на `logger.info(...)` (видимость
потери). Sweeper (F-1) всё равно подберёт такую задачу — это defense-in-depth, не критпуть.
- **Не менять** success/failure-семантику гейта.
## 5. Конфигурация (`src/config.py`, env-prefix `ORCH_`)
| Поле | Дефолт | Назначение |
|------|--------|-----------|
| `reconcile_enabled` | `True` | глобальный kill-switch sweeper'а (self-hosting safety, поэтапный раскат). |
| `reconcile_interval_s` | `120` | период фонового прохода (сек). |
| `reconcile_plane_enabled` | `True` | отдельный флаг для F-2 (опрос Plane API), чтобы можно было гасить только plane-ветку. |
| `reconcile_grace_default_s` | `600` | дефолтный порог «застревания» по `tasks.updated_at`. |
| `reconcile_grace_overrides_json` | `""` | JSON-объект per-stage порогов, напр. `{"analysis": 1800, "development": 300, "deploy": 900}`. Невалидный JSON → дефолт (как `agent_timeout_overrides_json`). |
| `reconcile_notify_unblock` | `True` | слать Telegram при разблокировке (F-4). |
`grace_for_stage(stage)` = override из JSON, иначе `reconcile_grace_default_s`.
## 6. БД
- **Изменения схемы НЕ требуются** (предпочтительно, по образцу merge-gate ORCH-043).
Стуковость определяется по существующим `tasks.updated_at`, `tasks.stage` и таблице `jobs`
(`has_active_job_for_task`). `update_task_stage` уже обновляет `updated_at`.
- Если архитектор сочтёт необходимым анти-дребезг (`tasks.last_reconcile_at`) — допускается
идемпотентная миграция через `_ensure_column` (как остальные ALTER в `db.py`). По умолчанию
— **без новых колонок**.
## 7. API (опционально, F-4)
- Расширить `GET /queue` блоком `"reconcile": {...}` (enabled, interval, last_run_ts,
unblocked_total, last_unblocked) — по образцу `worker.status()`.
- ИЛИ добавить `GET /reconcile` с теми же метриками. Выбор — архитектор. Не обязательно для
прохождения AC, но крайне желательно для наблюдаемости.
## 8. Новые QG checks
- **Нет.** Sweeper переиспускает существующие гейты из `QG_CHECKS` через `advance_stage`.
Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются.
## 9. Артефакты pipeline / документация (обязательно в ЭТОМ PR)
- `docs/architecture/README.md` — раздел про reconciler (компонент + место в resilience-слое).
- `docs/work-items/ORCH-053/06-adr/ADR-001-*.md` — архитектурное решение (потоки, гонки,
async-вызов обработчиков, подавление спама, grace-критерий, atomic-claim на создании).
- `CHANGELOG.md` — запись `feat: ORCH-053 stuck-task reconciler`.
- При желании архитектора — global ADR в `docs/architecture/adr/` (сквозной resilience).
- `docs/operations/INFRA.md` — упомянуть kill-switch `ORCH_RECONCILE_ENABLED` (self-hosting).
## 10. Нефункциональные требования
- **Never-raise в тике:** исключение в обработке одной задачи/issue не должно ронять весь
проход (изолировать try/except на единицу работы, как `queue_worker._drain_once`).
- **Идемпотентность** — см. §2/§3.
- **Restart-safe** — daemon-поток + `threading.Event`, чистый `stop()` в `lifespan.finally`.
- **Тишина при синхронности** — нет действий → нет логов уровня INFO/нотификаций.
- **Тесты** — см. `04-test-plan.yaml` (моки Plane/Gitea API и QG, без реальной сети).

View File

@@ -0,0 +1,116 @@
# Acceptance Criteria — ORCH-053
Work Item ID: ORCH-053
Формат: каждый критерий имеет явное условие PASS/FAIL. Критерий считается выполненным,
только если соответствующие тесты из `04-test-plan.yaml` зелёные.
## AC-1 — Реконсиляция застрявшей стадии (gate-side, F-1)
- **Дано:** task на стадии `development`, без активных job'ов, `updated_at` старше grace,
гейт `check_ci_green` для её branch — зелёный (CI прошёл, но webhook потерян, как ORCH-044).
- **Когда:** срабатывает фоновый проход `reconcile_gate_once()`.
- **PASS:** задача продвинута `development → review`, заenqueuen `reviewer` (через
`advance_stage(..., finished_agent=None)`), `tasks.updated_at` обновлён.
- **FAIL:** задача осталась на `development`, либо продвижение пошло параллельной логикой
(не через `advance_stage`).
## AC-2 — Источник истины — гейт, не событие
- **PASS:** продвижение в F-1 выполняется исключительно вызовом
`stage_engine.advance_stage(...)`; в `reconciler.py` НЕТ собственного
`update_task_stage`+`enqueue_job` для advance стадии (только переиспользование).
- **FAIL:** в reconciler продублирована логика advance/rollback.
## AC-3 — Идемпотентность: sweeper не трогает задачи с активным job
- **Дано:** task с `queued` или `running` job (`has_active_job_for_task == True`).
- **PASS:** sweeper пропускает задачу — ни advance, ни enqueue, ни нотификации.
- **FAIL:** sweeper дёргает гейт / создаёт второй job для такой задачи.
## AC-4 — Идемпотентность: задержавшийся/дублированный webhook + sweeper не двоят
- **Дано:** issue в Plane = In Progress, задержавшийся Plane-webhook ещё не обработан.
- **Когда:** F-2 реконсилирует И затем (или одновременно) приходит реальный webhook.
- **PASS:** создаётся **ровно одна** задача (один task row, один branch/worktree, один
стартовый analyst-job). Повторный путь видит существующую задачу/активный job и не двоит.
- **FAIL:** созданы две задачи / два стартовых job / два worktree на один `plane_id`.
## AC-5 — Per-stage grace соблюдается
- **Дано:** task на стадии, чей `updated_at` свежее grace этой стадии (агент легитимно
работает, напр. analysis 8 мин при grace 1800с).
- **PASS:** sweeper НЕ трогает задачу (не дёргает гейт).
- **PASS (граница):** как только `age(updated_at) >= grace_for_stage(stage)` и нет активного
job — задача становится кандидатом.
- **FAIL:** sweeper дёргает гейт у задачи в пределах grace.
## AC-6 — Plane In Progress без задачи → запуск (F-2)
- **Дано:** issue в Plane = In Progress (статус сменён руками, webhook потерян), в `tasks`
задачи нет, прошёл grace.
- **PASS:** sweeper вызывает `handle_status_start`/`start_pipeline` → задача создана,
заenqueuen analyst — как если бы пришёл webhook.
- **FAIL:** задача не создана; либо создана дублирующей логикой, минуя `handle_status_start`.
## AC-7 — Plane Approved без advance → advance (F-2)
- **Дано:** issue = Approved, task существует и стадия НЕ сдвинута, нет активного job, прошёл grace.
- **PASS:** sweeper вызывает `handle_verdict(approved=True)` → штатный advance.
- **FAIL:** нет advance, либо advance вне `handle_verdict`/`advance_stage`.
## AC-8 — Plane Rejected без rollback → rollback (F-2)
- **Дано:** issue = Rejected, task существует и не откатана, нет активного job, прошёл grace.
- **PASS:** sweeper вызывает `handle_verdict(approved=False)` → штатный rollback на предыдущую стадию.
- **FAIL:** нет rollback, либо rollback вне штатного пути.
## AC-9 — Нет спама нотификаций на красном гейте
- **Дано:** застрявшая задача, у которой гейт стабильно **красный** (напр. CI failure),
нет активного job, прошёл grace.
- **Когда:** sweeper проходит несколько тиков подряд.
- **PASS:** `notify_qg_failure`/Telegram НЕ вызывается на каждом тике (≤1 раз / без
повторов); задача не продвигается.
- **FAIL:** на каждом тике летит нотификация о провале гейта.
## AC-10 — Тишина при синхронности
- **Дано:** все задачи синхронны (нет застрявших; статусы Plane совпадают с локальными).
- **PASS:** проход не выполняет действий, не пишет INFO-логов о разблокировке, не шлёт нотификаций.
- **FAIL:** sweeper генерирует шум/действия при полностью синхронном состоянии.
## AC-11 — Restart-safe фоновый поток
- **PASS:** reconciler стартует в `main.lifespan` (daemon-поток), корректно
останавливается (`stop()`), переживает рестарт сервиса без потери (нет состояния в памяти,
критичного для корректности; всё перечитывается из БД/Plane).
- **FAIL:** reconciler не стартует автоматически, висит при shutdown, или дублирует действия
после рестарта.
## AC-12 — Наблюдаемость разблокировки (F-4)
- **Дано:** sweeper разблокировал застрявшую задачу.
- **PASS:** в лог пишется явная строка вида
`reconciler: <work_item_id> <stage> разблокирована (потерян webhook)`;
при `reconcile_notify_unblock=True` — Telegram-уведомление.
- **FAIL:** разблокировка происходит молча (невозможно измерить частоту дыры).
## AC-13 — Kill-switch
- **Дано:** `reconcile_enabled=False` (env `ORCH_RECONCILE_ENABLED=false`).
- **PASS:** фоновый поток reconciler не выполняет проходов (или не стартует); система
работает как до ORCH-053. `reconcile_plane_enabled=False` гасит только F-2, F-1 работает.
- **FAIL:** sweeper активен при выключенном флаге.
## AC-14 — Усиленный sha→branch резолв (F-3)
- **Дано:** Gitea CI-status webhook без `branches` и со `sha`, не разрезолвившимся
через `git branch -r --contains`.
- **PASS:** добавленный БД-fallback однозначно находит task (по repo + активной
development-стадии) и продвигает; неоднозначность логируется на уровне INFO; существующая
success/failure-семантика гейта не изменена.
- **FAIL:** регресс существующего резолва, либо ложный матч при неоднозначности.
## AC-15 — Never-raise в тике
- **Дано:** обработка одной задачи/issue кидает исключение (битые данные, ошибка API).
- **PASS:** исключение изолировано, проход продолжает остальные задачи; поток не падает.
- **FAIL:** одно исключение роняет весь проход / поток reconciler.
## AC-16 — F-1 не продвигает analysis по локальному состоянию
- **Дано:** task на `analysis`, артефакты на диске присутствуют, но Plane НЕ в статусе
Approved (BRD не одобрен человеком), нет активного job, прошёл grace.
- **PASS:** F-1 (gate-side) НЕ продвигает analysis→architecture (advance стадии analysis
отдан F-2, которая сверяется с реальным статусом Plane Approved).
- **FAIL:** sweeper автопродвинул неодобренный BRD.
## AC-17 — Документация обновлена (golden source)
- **PASS:** в PR обновлены `docs/architecture/README.md`, заведён
`docs/work-items/ORCH-053/06-adr/ADR-001-*.md`, обновлён `CHANGELOG.md`, упомянут
kill-switch в `docs/operations/INFRA.md`.
- **FAIL:** код изменён, документация — нет (Reviewer обязан вернуть REQUEST_CHANGES).

View File

@@ -0,0 +1,200 @@
work_item: ORCH-053
description: >
Тесты sweeper/reconciler потерянных webhook. Вся сеть (Plane API, Gitea API, QG)
мокируется (monkeypatch), как в существующих tests/. Telegram заглушён autouse-фикстурой
conftest. Используется временная SQLite БД (ORCH_DB_PATH / фикстура setup_db по образцу
test_webhooks.py / test_queue.py). Реальные агенты/CLI не запускаются.
tests:
# ---- F-1: gate-side sweeper -------------------------------------------------
- id: TC-01
type: unit
description: >
reconcile_gate_once продвигает застрявшую development-задачу: нет активных job,
updated_at старше grace, check_ci_green замокан в (True, "CI green") →
advance_stage вызван, стадия стала review, заenqueuen reviewer.
module: tests/test_reconciler.py
expected: PASS
- id: TC-02
type: unit
description: >
Источник истины — гейт: reconciler НЕ содержит собственного update_task_stage/
enqueue_job для advance — продвижение идёт строго через stage_engine.advance_stage
(проверка через мок/spy advance_stage, вызван с finished_agent=None).
module: tests/test_reconciler.py
expected: PASS
- id: TC-03
type: unit
description: >
Задача с активным job (has_active_job_for_task=True) пропускается: гейт не дёргается,
advance_stage не вызывается, нотификаций нет.
module: tests/test_reconciler.py
expected: PASS
- id: TC-04
type: unit
description: >
Per-stage grace: задача с updated_at свежее grace своей стадии не трогается;
ровно на границе age>=grace и без активного job — становится кандидатом.
module: tests/test_reconciler.py
expected: PASS
- id: TC-05
type: unit
description: >
grace_for_stage читает reconcile_grace_overrides_json (per-stage), при отсутствии
ключа — reconcile_grace_default_s; невалидный JSON → дефолт, не падает.
module: tests/test_reconciler.py
expected: PASS
- id: TC-06
type: unit
description: >
Нет спама: при стабильно красном гейте (check_ci_green=(False,...)) несколько проходов
подряд НЕ вызывают notify_qg_failure повторно на каждом тике; задача не продвигается.
module: tests/test_reconciler.py
expected: PASS
- id: TC-07
type: unit
description: >
Тишина при синхронности: когда все задачи done / имеют активный job / в пределах grace —
проход не вызывает advance_stage и не пишет INFO-логов о разблокировке.
module: tests/test_reconciler.py
expected: PASS
- id: TC-08
type: unit
description: >
AC-16: задача на analysis с артефактами на диске, но Plane НЕ Approved — F-1
(reconcile_gate_once) НЕ продвигает analysis→architecture.
module: tests/test_reconciler.py
expected: PASS
- id: TC-09
type: unit
description: >
Never-raise: если обработка одной задачи кидает исключение (advance_stage замокан на
raise), проход ловит его и продолжает обрабатывать остальные задачи; поток не падает.
module: tests/test_reconciler.py
expected: PASS
- id: TC-10
type: unit
description: >
Kill-switch: при reconcile_enabled=False reconcile_gate_once/plane_once не выполняют
действий (no-op); при reconcile_plane_enabled=False гасится только F-2.
module: tests/test_reconciler.py
expected: PASS
# ---- F-2: plane-side reconciler --------------------------------------------
- id: TC-11
type: unit
description: >
In Progress без задачи: list_issues_by_state возвращает issue в In Progress, в БД задачи
нет → reconcile_plane_once вызывает handle_status_start (мок) ровно один раз с корректным
issue_data (id/state/project).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-12
type: unit
description: >
Approved без advance: issue=Approved, task существует, нет активного job → вызван
handle_verdict(approved=True) (мок) один раз.
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-13
type: unit
description: >
Rejected без rollback: issue=Rejected, task существует, нет активного job → вызван
handle_verdict(approved=False) (мок) один раз.
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-14
type: unit
description: >
Идемпотентность F-2: issue в требующем-действия статусе, но у task есть активный job →
handle_status_start/handle_verdict НЕ вызываются (живой webhook в работе).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-15
type: integration
description: >
AC-4 анти-дубль на создании: одновременная реконсиляция + обработка реального In Progress
webhook для одного plane_id создают ровно ОДИН task row и один стартовый analyst-job
(реальная временная БД, мок Gitea/Plane сетевых вызовов).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-16
type: unit
description: >
list_issues_by_state never-raise: при ошибке Plane API (httpx бросает / non-2xx) →
возвращает [], тик не падает; при успехе — обходит пагинацию и фильтрует по state.
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-17
type: unit
description: >
F-2 опрашивает все проекты реестра projects.PROJECTS и резолвит state-uuid через
get_project_states per-project (enduro + orchestrator), не хардкодит uuid.
module: tests/test_reconciler_plane.py
expected: PASS
# ---- F-3: sha→branch резолв -------------------------------------------------
- id: TC-18
type: unit
description: >
handle_ci_status: при отсутствии branches и неразрезолвленном sha срабатывает БД-fallback
и однозначно находит единственную development-задачу repo; продвижение идёт штатно.
module: tests/test_gitea_sha_resolve.py
expected: PASS
- id: TC-19
type: unit
description: >
handle_ci_status: при неоднозначности (несколько development-задач repo) БД-fallback не
делает ложный матч (branch остаётся неразрезолвленным, лог INFO), success/failure-семантика
гейта не изменена.
module: tests/test_gitea_sha_resolve.py
expected: PASS
# ---- F-4 / интеграция фонового потока --------------------------------------
- id: TC-20
type: unit
description: >
Наблюдаемость: при разблокировке reconciler пишет явную лог-строку с work_item_id и
stage; при reconcile_notify_unblock=True вызывается send_telegram (замокан).
module: tests/test_reconciler.py
expected: PASS
- id: TC-21
type: integration
description: >
Restart-safe поток: Reconciler.start() поднимает daemon-поток, stop() завершает его
в пределах таймаута; повторный start идемпотентен (не плодит второй поток).
module: tests/test_reconciler.py
expected: PASS
- id: TC-22
type: unit
description: >
Конфиг: новые поля reconcile_* присутствуют в Settings с заявленными дефолтами и
читаются из env с префиксом ORCH_ (по образцу tests/test_config.py).
module: tests/test_config.py
expected: PASS
- id: TC-23
type: unit
description: >
Регресс реестров: STAGE_TRANSITIONS и QG_CHECKS не изменены ORCH-053
(snapshot-тест проходит как раньше).
module: tests/test_qg_registry_snapshot.py
expected: PASS

View File

@@ -0,0 +1,221 @@
# ADR-001: Sweeper/reconciler потерянных webhook (реконсиляция застрявших стадий)
- **Статус:** Proposed
- **Дата:** 2026-06-06
- **Задача:** ORCH-053
- **Сквозной ADR:** `docs/architecture/adr/adr-0007-reconciler.md`
- **Связи:** adr-0001 (реестр проектов), adr-0002 (очередь / `available_at`),
adr-0003 (условный staging-гейт — образец условности), adr-0006 (merge-gate как
под-гейт ребра), ORCH-5 (events de-dup), ORCH-045 (`ci_poll`).
## Контекст
Продвижение задач по конвейеру завязано **исключительно** на входящие webhook
(Plane status / Gitea CI/PR). Потерянное событие (502 на ребилдящемся инстансе,
Plane/Gitea не ретраят, `sha→branch` не разрезолвился) → источник истины (Plane /
зелёный CI) изменился, а задача в оркестраторе застряла молча (живой инцидент
ORCH-044). Ни один существующий механизм resilience (`requeue_running_jobs`,
orphan-recovery, events de-dup, `ci_poll`) не реконсилирует рассинхрон
**«источник истины ≠ стадия задачи»** — все они работают на уровне jobs/agent_runs,
а не stage-перехода.
ТЗ (`02-trz.md`) фиксирует объём; данный ADR фиксирует архитектурные развилки,
явно отданные архитектору: (1) потокобезопасность и подавление спама нотификаций,
(2) способ вызова `async`-обработчиков `plane.py` из sync-потока, (3) atomic-claim
на создании задачи (анти-дубль), (4) критерий «потерян, а не задержан» (grace),
(5) отсутствие изменений схемы БД.
## Решение
### 1. Компонент: `src/reconciler.py` — фоновый daemon-поток
Новый модуль по образцу `queue_worker.py`: класс `Reconciler` + module-singleton
`reconciler`. Plain `threading.Thread(daemon=True)` + `threading.Event` для
остановки. Стартует в `main.lifespan` **после** `worker.start()`, останавливается в
`finally` **перед** `worker.stop()`. Цикл:
```
while not stop:
try:
if settings.reconcile_enabled:
reconcile_gate_once() # F-1
if settings.reconcile_plane_enabled:
reconcile_plane_once() # F-2
except Exception: log.error(...) # outer never-raise
stop.wait(settings.reconcile_interval_s)
```
`start()` идемпотентен (как `QueueWorker.start`: если поток жив — no-op), что
покрывает AC-11 (повторный start не плодит второй поток). Никакого критичного
состояния в памяти — всё перечитывается из БД/Plane на каждом тике; метрики
наблюдаемости (`last_run_ts`, `unblocked_total`) — best-effort, теряются при
рестарте (AC-11 это явно допускает).
### 2. Источник истины — гейт, не событие. Продвижение строго через `advance_stage`
F-1 НЕ дублирует логику advance. Вводится тонкий хелпер в `stage_engine.py`:
```python
def advance_if_gate_passed(task_id, stage, repo, work_item_id, branch) -> AdvanceResult | None
```
Алгоритм:
1. `stage == "analysis"` → немедленный возврат `None` (см. §6, AC-16).
2. `qg = get_qg_for_stage(stage)`; если `None` (created/done) → возврат `None`.
3. **Read-only пред-оценка гейта** тем же диспетчером, что использует webhook-путь:
`passed, reason = _run_qg(qg, repo, work_item_id, branch)`.
4. **passed** → вызвать `advance_stage(task_id, stage, repo, work_item_id, branch,
finished_agent=None)` — это **тот же** путь, которым продвигает Plane
Approved-webhook (`webhooks/plane._try_advance_stage`). Он повторно прогонит
гейт (гейты идемпотентны/read-only), продвинет стадию, отправит **штатные**
advance-нотификации и поставит следующего агента.
5. **not passed** → **тишина**: `logger.debug(...)`, возврат `None`. Никаких
`notify_qg_failure` / `plane_notify_qg`.
Это даёт оба контракта одновременно:
- **AC-2 / TC-02:** в `reconciler.py` нет собственного `update_task_stage` +
`enqueue_job` для advance — продвижение исключительно через `advance_stage(...,
finished_agent=None)`.
- **AC-9 / TC-06:** на застрявшей-но-красной задаче `advance_stage` **не
вызывается вовсе**, поэтому ветка нотификации провала (`agent is None` →
`notify_qg_failure`+`plane_notify_qg`, `stage_engine.py:228-230`) не
срабатывает ни на одном тике. Спам структурно невозможен.
**Подавление спама = «не вызывать advance_stage на красном гейте»**, а не флаг
внутри `advance_stage`. Это сохраняет унифицированный критический путь
(`advance_stage`) **без изменений** — минимальный blast-radius для self-hosting.
> **Цена (осознанная):** на «зелёной» задаче гейт оценивается дважды (пред-оценка
> в хелпере + повтор внутри `advance_stage`). Гейты — чистые read-only проверки
> (`check_ci_green`, `check_*_status` из `12/13/14/15`), на реально-застрявшей-но-
> готовой задаче (целевой кейс ORCH-044) возвращаются быстро (CI уже зелёный →
> `ci_poll` отдаёт результат на первой итерации). Двойная оценка приемлема ради
> неизменности `advance_stage`.
#### Отклонённая альтернатива: флаг `suppress_qg_failure_notify` в `advance_stage`
Однократная оценка гейта, но изменяет сигнатуру и поведение общего
критического пути (риск для self-hosting, обслуживающего все проекты). Отклонено
в пользу неизменности `advance_stage` (Option A выше).
### 3. F-2: вызов `async`-обработчиков `plane.py` из sync-потока
Reconciler — sync daemon-поток; `handle_status_start` / `handle_verdict` —
`async`. Решение: вызывать через **`asyncio.run(coro)`** на каждую единицу работы
внутри per-issue `try/except`. `asyncio.run` создаёт свежий event loop на вызов,
что необходимо, т.к. `handle_verdict → _try_advance_stage` использует
`asyncio.to_thread` (требует running loop). Логику **не дублировать** —
переиспользуются ровно `handle_status_start` / `handle_verdict` /
`list_issues_by_state`.
`issue_data` собирается в форму, ожидаемую обработчиками (`{"id", "state":{"id":..},
"project", "name", "description_stripped"}`); недостающие name/description
обработчики сами дотянут через `fetch_issue_fields` (как для status-only webhook).
### 4. Идемпотентность создания (анти-дубль, AC-4) — atomic-claim в БД
Гонка: F-2 видит `In Progress` + нет задачи; одновременно реальный webhook тоже
видит `In Progress` + нет задачи → оба проходят `get_task_by_plane_id() is None`
→ два `start_pipeline` → два task-row / branch / worktree / стартовых analyst-job
(events de-dup тут НЕ помогает: reconciler — не webhook-доставка).
Решение: **atomic-claim создания, защищённый process-wide `threading.Lock`**.
Новый хелпер `db.create_task_atomic(plane_id, ...)` выполняет
`SELECT-exists → INSERT` под module-level `Lock`, возвращая `(row, created: bool)`:
только победитель (`created=True`) продолжает branch/docs/analyst; проигравший
видит существующую задачу и выходит. `start_pipeline` рефакторится так, чтобы
**первым** DB-действием был этот claim; reconciler идёт тем же путём через
`handle_status_start` → `start_pipeline`.
**Обоснование выбора Lock, а не UNIQUE-индекса:**
- Прод — **один процесс** uvicorn на одну БД (staging/prod изолированы своими БД);
webhook исполняется в asyncio-треде uvicorn, reconciler — в своём треде того же
процесса → `threading.Lock` покрывает обе стороны гонки.
- **Без миграции схемы** (соответствует §6 ТЗ и образцу merge-gate ORCH-043).
`CREATE UNIQUE INDEX` на `tasks.plane_id` рискует упасть на проде, если там уже
существуют дубли `plane_id` (исторические) — а проверить это вживую нельзя.
- Дешёвый fast-path `get_task_by_plane_id` сохраняется до claim.
> **Граница применимости:** гарантия верна для single-process деплоя (текущая
> топология). Многопроцессный запуск (`uvicorn --workers N`) потребовал бы
> DB-native UNIQUE-индекса — задокументировано как будущее упрочнение в
> `08-data-requirements.md`. Очередь (`queue_worker`) уже опирается на ту же
> single-process-singleton модель, так что допущение не новое.
### 5. Анти-гонка с живым webhook (AC-3) — active-job guard + grace
- **Active-job guard:** `has_active_job_for_task(task.id) == True` → задача
легитимно в работе или живой webhook только что поставил job → **skip** (ни
пред-оценки гейта, ни advance, ни нотификаций). И в F-1, и в F-2.
- **Самозатухание повторов:** `advance_stage → update_task_stage` обновляет
`tasks.updated_at` → следующий тик увидит свежий `updated_at` и не сработает
повторно (grace).
- `max_concurrency=1`: новый enqueued job встаёт в общую очередь — двойного
запуска нет (atomic `claim_next_job`).
### 6. F-1 НЕ реконсилирует `analysis` (AC-16)
Гейт `check_analysis_approved` — **человеческий**. В `advance_stage` при
`finished_agent=None` он трактуется как `approved-via-status` и продвинул бы
задачу. Но при потере именно **Approved**-webhka продвигать analysis допустимо
**только** если Plane реально в статусе Approved — этого локальная БД не знает.
Поэтому advance стадии `analysis` отдан **F-2** (сверяется с реальным статусом
Plane). `advance_if_gate_passed` для `stage == "analysis"` — ранний возврат
`None`. Защита от автопродвижения неодобренного человеком BRD.
### 7. Grace: критерий «потерян, а не задержан»
- **F-1:** кандидат, если `has_active_job_for_task == False` **и**
`age(tasks.updated_at) >= grace_for_stage(stage)`.
`grace_for_stage(stage)` = per-stage override из `reconcile_grace_overrides_json`,
иначе `reconcile_grace_default_s`. Невалидный JSON → дефолт (паттерн
`agent_timeout_overrides_json`, never-raise).
- **F-2:** источник «давности» — поле `updated_at` **issue из Plane** (когда статус
реально сменился). Реагировать только если `age(issue.updated_at) >=
reconcile_grace_default_s` — отсекает просто задержавшийся webhook. Для
существующей задачи дополнительно требуется отсутствие активного job.
### 8. F-3: усиление `sha→branch` в `handle_ci_status`
При неразрезолвленном branch (нет `branches`, `git branch -r --contains` пуст) —
fallback на БД: найти task'и repo со `stage='development'`; при **однозначности**
(ровно одна) использовать её branch; при неоднозначности — оставить
неразрезолвленным + `logger.info`. `logger.debug → logger.info` для видимости.
Success/failure-семантика гейта не меняется. Defense-in-depth: F-1 всё равно
подберёт такую задачу.
### 9. БД и реестры — без изменений
- Схема **не меняется** (§6 ТЗ). Стуковость — по `tasks.updated_at`/`tasks.stage`
+ `has_active_job_for_task`. Анти-дребезг колонкой `last_reconcile_at` **не
нужен**: на красном гейте действий/нотификаций нет вовсе (§2), а после advance
`updated_at` обновляется → повтор невозможен.
- `STAGE_TRANSITIONS` и `QG_CHECKS` **не меняются** (AC / TC-23). Новых QG нет.
### 10. Наблюдаемость (F-4)
- При **разблокировке** (произошёл advance) — явная лог-строка
`reconciler: <work_item_id> <stage> разблокирована (потерян webhook)`; при
`reconcile_notify_unblock=True` — `send_telegram(...)`. Только на изменении
состояния, не на каждый тик (AC-12, не конфликтует с AC-9/AC-10).
- `/queue` расширяется блоком `"reconcile": {enabled, plane_enabled, interval,
last_run_ts, unblocked_total, last_unblocked}` по образцу `worker.status()`.
## Альтернативы (сводно)
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (§2): изменяет общий
критический путь.
- **UNIQUE-индекс на `tasks.plane_id`** — отклонён как primary (§4): риск падения
миграции на проде; задокументирован как будущее упрочнение для multi-process.
- **Отдельная стадия/QG для реконсиляции** — вне объёма; нарушило бы «источник
истины — существующий гейт».
- **Реконсиляция analysis по локальным артефактам** — отклонена (§6): риск
автопродвижения неодобренного BRD.
## Последствия
- Потерянный webhook больше не = молча застрявшая задача; ручной heartbeat-watchdog
Стрима не нужен; резервная сетка к ORCH-51/ORCH-36.
- Плата: фоновый поток + периодический опрос Plane API (нагрузка — митигируется
интервалом + фильтром по статусам + per-project); двойная оценка гейта на зелёной
задаче; анти-дубль на создании опирается на single-process-допущение.
- Self-hosting: kill-switch `reconcile_enabled` обязателен; reconciler не
рестартит/не роняет прод-контейнер; раскат поэтапный (флаги).
- Сквозной resilience-механизм → сопровождается global `adr-0007`.

View File

@@ -0,0 +1,45 @@
# 07 — Требования к инфраструктуре — ORCH-053
Work Item ID: ORCH-053
## Топология
**Без изменений.** Новых контейнеров/портов/сервисов нет. Reconciler — фоновый
daemon-поток **внутри** существующего процесса orchestrator (как `queue_worker`).
Стартует/останавливается в `main.lifespan`. Деплой ORCH-053 — строго через
staging-гейт (8501) перед прод-деплоем (self-hosting, см. `docs/operations/INFRA.md`).
## Новые переменные окружения (`.env` / `.env.staging` на хосте, префикс `ORCH_`)
| Env | Поле `Settings` | Дефолт | Назначение |
|-----|-----------------|--------|-----------|
| `ORCH_RECONCILE_ENABLED` | `reconcile_enabled` | `true` | **Kill-switch** всего sweeper'а (self-hosting safety, поэтапный раскат, аварийное отключение). |
| `ORCH_RECONCILE_INTERVAL_S` | `reconcile_interval_s` | `120` | Период фонового прохода (сек). |
| `ORCH_RECONCILE_PLANE_ENABLED` | `reconcile_plane_enabled` | `true` | Отдельный флаг F-2 (опрос Plane API); `false` гасит только plane-ветку, F-1 работает. |
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | `reconcile_grace_default_s` | `600` | Дефолтный порог «застревания» по `tasks.updated_at` / `issue.updated_at`. |
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | `reconcile_grace_overrides_json` | `""` | Per-stage пороги, напр. `{"analysis":1800,"development":300,"deploy":900}`. Невалидный JSON → дефолт (never-raise). |
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | `reconcile_notify_unblock` | `true` | Telegram при разблокировке (F-4). |
Секреты не добавляются. `.env.example` (канон) обновляется в PR реализации.
## Нагрузка / сеть
- **Plane API (F-2):** GET issues per-project каждые `reconcile_interval_s`, с
фильтром по статусам (In Progress / Approved / Rejected) и пагинацией. Митигация
нагрузки — интервал (120с), фильтр, per-project, never-raise (Plane outage →
`[]`, тик не падает). `get_project_states` уже кэширует state-uuid per-project.
- **Gitea API (F-1):** только косвенно — внутри переоценки гейтов (`check_ci_green`
и т.п.), которые и так вызываются webhook-путём. Дополнительных постоянных
вызовов reconciler не вносит сверх момента реальной разблокировки.
- **CPU/RAM:** один спящий daemon-поток; всплеск только при наличии застрявших
задач.
## Self-hosting
- Reconciler **не** рестартит/не роняет прод-контейнер `orchestrator` (8500),
обслуживающий все проекты с общей БД.
- `docs/operations/INFRA.md` дополняется упоминанием kill-switch
`ORCH_RECONCILE_ENABLED` (выполняется в PR реализации, §9 ТЗ).
- Раскат: при первом деплое допустимо стартовать с `ORCH_RECONCILE_PLANE_ENABLED=false`
(только F-1, минимальный риск), затем включить F-2.
## Конфиги/деплой
Дополнительных томов, портов, healthcheck'ов, изменений `docker-compose`/Dockerfile
**не требуется**.

View File

@@ -0,0 +1,38 @@
# 08 — Требования к данным / схеме БД — ORCH-053
Work Item ID: ORCH-053
## Изменения схемы: НЕТ
Реконсиляция строится исключительно на существующих структурах (по образцу
merge-gate ORCH-043 — «без новых колонок»):
| Структура | Использование reconciler |
|-----------|--------------------------|
| `tasks.stage` | Кандидаты F-1: `stage NOT IN ('done')`; `created`/`analysis` отфильтровываются (нет QG / человеческий гейт). |
| `tasks.updated_at` | Критерий «застряла»: `age(updated_at) ≥ grace_for_stage(stage)`. `update_task_stage` уже штампует `updated_at` → самозатухание повторов. |
| `tasks.repo`, `tasks.branch`, `tasks.work_item_id`, `tasks.plane_id` | Аргументы `advance_stage` / резолв задачи. |
| `jobs` (`has_active_job_for_task`) | Active-job guard (AC-3): задача с `queued`/`running` job не трогается. |
## Анти-дребезг (`last_reconcile_at`): НЕ вводится
На красном гейте reconciler не делает ни advance, ни нотификаций (см. ADR-001 §2),
поэтому спама нет структурно; после успешного advance обновляется `updated_at`
повтор невозможен. Дополнительная колонка для дебаунса не нужна.
## Идемпотентность создания (анти-дубль, AC-4)
Гонка reconciler↔webhook на создании задачи закрывается **process-wide
`threading.Lock`** вокруг `SELECT-exists → INSERT` (новый хелпер
`db.create_task_atomic`), **без** изменения схемы. Гарантия верна для текущей
**single-process** топологии (один uvicorn на одну БД; staging/prod изолированы) —
тот же допущение, что у очереди `queue_worker` (ORCH-1).
### Будущее упрочнение (вне объёма ORCH-053)
Для multi-process деплоя (`uvicorn --workers N`) потребуется DB-native гарантия:
частичный UNIQUE-индекс `CREATE UNIQUE INDEX ... ON tasks(plane_id) WHERE plane_id
IS NOT NULL` (паттерн `idx_events_delivery`) + `INSERT OR IGNORE` claim. Не вводим
сейчас: миграция может упасть на проде при наличии исторических дублей `plane_id`
(проверить вживую нельзя); требует отдельной задачи с аудитом данных.
## Миграции
Не требуются. Если в будущем понадобится колонка — только идемпотентный
`_ensure_column` (как все ALTER в `src/db.py`), безопасный на живой прод-БД.

View File

@@ -0,0 +1,27 @@
# 10 — Технические риски — ORCH-053
Work Item ID: ORCH-053
Severity: 🔴 high / 🟡 medium / 🟢 low
| # | Риск | Sev | Митигация (где зафиксировано) |
|---|------|-----|-------------------------------|
| R-1 | **Гонка reconciler↔живой webhook → двойная задача** (оба видят «нет задачи» на `In Progress`). | 🔴 | Atomic-claim `db.create_task_atomic` под process-wide `threading.Lock` (ADR-001 §4, 08-data). AC-4 / TC-15. |
| R-2 | **Двойной запуск агента** на стадии (reconciler дёргает гейт у задачи в работе). | 🔴 | `has_active_job_for_task` guard + `max_concurrency=1` + atomic `claim_next_job`; `update_task_stage` обновляет `updated_at` (ADR-001 §5). AC-3 / TC-03. |
| R-3 | **Спам нотификаций** на стабильно красном гейте каждый тик. | 🔴 | «Не вызывать `advance_stage` на красном» → ветка `notify_qg_failure` не достигается (ADR-001 §2). AC-9 / TC-06. |
| R-4 | **Автопродвижение неодобренного BRD** (F-1 продвинул `analysis` без Approved в Plane). | 🔴 | F-1 не реконсилирует `analysis`; advance стадии — только F-2 по реальному статусу Plane (ADR-001 §6). AC-16 / TC-08. |
| R-5 | **Дублирование логики advance/rollback** в reconciler (расхождение с webhook-путём со временем). | 🟡 | Продвижение строго через `advance_stage(..., finished_agent=None)`; F-2 — через `handle_*` из `plane.py`; своего `update_task_stage`/`enqueue_job` для advance нет (ADR-001 §2-3). AC-2 / TC-02. |
| R-6 | **Падение тика из-за одной битой задачи/issue** (битые данные, ошибка API). | 🟡 | Per-task / per-issue `try/except` + outer `try/except` в `_run` (паттерн `_drain_once`). AC-15 / TC-09. `list_issues_by_state` never-raise → `[]`. TC-16. |
| R-7 | **Нагрузка/недоступность Plane API** при опросе каждые N сек. | 🟡 | Интервал 120с + фильтр по статусам + per-project + кэш `get_project_states`; never-raise → мягкая деградация (ADR-001 §3, 07-infra). |
| R-8 | **`asyncio.run` из sync-потока** (event loop конфликты, зависание). | 🟡 | Свежий loop на единицу работы; внутри per-issue try/except; нет вложенного running loop (reconciler — не async). ADR-001 §3. |
| R-9 | **Self-hosting: reconciler меняет инструмент всех проектов** / нежелательное срабатывание на проде. | 🔴 | Kill-switch `reconcile_enabled`; раздельный `reconcile_plane_enabled`; деплой через staging-гейт; не рестартит прод. ADR-001 §1, 07-infra. AC-13 / TC-10. |
| R-10 | **Двойная оценка гейта** на зелёной задаче (пред-оценка + повтор в `advance_stage`); долгий `ci_poll` держит тик. | 🟢 | Гейты идемпотентны/read-only; на целевом кейсе (CI уже зелёный) возвращаются быстро; reconciler — отдельный daemon-поток. Осознанная цена за неизменность `advance_stage` (ADR-001 §2). |
| R-11 | **Ложный `sha→branch` матч** в F-3 при неоднозначности. | 🟡 | БД-fallback срабатывает только при ровно одной `development`-задаче repo; иначе — неразрезолвлено + INFO; success/failure-семантика гейта не тронута (ADR-001 §8). AC-14 / TC-18, TC-19. |
| R-12 | **Регресс реестров** (`STAGE_TRANSITIONS`/`QG_CHECKS`) или схемы. | 🟡 | Реестры/схема не меняются; snapshot-тест (ADR-001 §9). AC / TC-23. |
| R-13 | **Дубль на стадии deploy-staging↔merge-gate** (reconciler триггерит advance, конкурируя с merge-lease). | 🟢 | F-1 продвигает только через `advance_stage`, который штатно прогоняет merge-gate (defer/rollback владеет исходом); active-job guard + `updated_at` — без гонки на тике (ADR-001 §2). |
| R-14 | **Multi-process деплой ломает анти-дубль** (Lock — внутрипроцессный). | 🟢 | Текущая топология single-process (как очередь ORCH-1); ограничение задокументировано, DB UNIQUE-индекс — будущее упрочнение (08-data). |
## Сводно
Самые острые (🔴) — анти-дубль на создании (R-1), двойной запуск (R-2), спам (R-3),
автопродвижение analysis (R-4), self-hosting (R-9) — закрыты явными механизмами с
покрытием в `04-test-plan.yaml`. Остаточные допущения: single-process топология
(R-14) и осознанная двойная оценка гейта (R-10).

View File

@@ -0,0 +1,88 @@
---
type: review
work_item_id: ORCH-053
verdict: APPROVED
version: 1
---
# Review ORCH-053 — Sweeper потерянных webhook (реконсиляция застрявших стадий)
## Summary
PR реализует фоновый reconciler застрявших стадий ровно в объёме ТЗ (`02-trz.md`) и
ADR (`06-adr/ADR-001`, глобальный `adr-0007`). Все 17 acceptance-criteria покрыты
кодом и тестами; полный прогон `pytest`**563 passed**. Реализация строго следует
ключевым инвариантам: продвижение только через неизменный `advance_stage(...,
finished_agent=None)`, никакой дублирующей advance/rollback-логики в `reconciler.py`,
структурная невозможность спама нотификаций, never-raise на единицу работы,
restart-safe daemon-поток, kill-switch'и. Схема БД и реестры `STAGE_TRANSITIONS` /
`QG_CHECKS` не тронуты. Документация обновлена в этом же PR. Рекомендация: **APPROVED**.
## Соответствие ТЗ
- `src/reconciler.py` (НОВЫЙ): F-1 `reconcile_gate_once` + F-2 `reconcile_plane_once`, класс
`Reconciler` + module-singleton по образцу `queue_worker`. ✓
- `src/config.py`: все 6 `reconcile_*` настроек с дефолтами по таблице §5. ✓
- `src/main.py`: старт после `worker.start()`, стоп перед `worker.stop()`, блок `reconcile`
в `GET /queue`. ✓
- `src/stage_engine.py`: тонкий `advance_if_gate_passed` — read-only пред-оценка гейта,
advance только через `advance_stage`, на красном гейте `advance_stage` не вызывается
вовсе (подавление спама без изменения общего критпути). ✓
- `src/plane_sync.py`: `list_issues_by_state` с курсорной пагинацией и never-raise → `[]`. ✓
- `src/webhooks/gitea.py`: F-3 БД-fallback `sha→branch` (`_resolve_branch_via_db`),
однозначность обязательна, `debug→info`. ✓
- `src/webhooks/plane.py` + `src/db.py`: F-2 переиспользует `handle_status_start` /
`handle_verdict` без дублирования; анти-дубль `create_task_atomic` под process-wide Lock,
`start_pipeline` рефакторен на atomic-claim первым DB-действием. ✓
- Схема БД и реестры не менялись (§6/§8 ТЗ). ✓
## Соответствие ADR
- §2 (источник истины — гейт; продвижение только через `advance_stage`): соблюдено —
в `reconciler.py` нет собственного `update_task_stage`/`enqueue_job` для advance (AC-2).
- §3 (async-обработчики из sync-потока через `asyncio.run`): реализовано в `_dispatch`.
- §4 (atomic-claim под `threading.Lock`, без миграции): `db.create_task_atomic`.
- §6 (F-1 не трогает `analysis`): ранний возврат в `advance_if_gate_passed` и в
`_reconcile_gate_task` (AC-16).
- §7 (grace «потерян, а не задержан»): F-1 по `tasks.updated_at` (SQL `age_s`), F-2 по
`issue.updated_at` (`_age_seconds_iso`).
- Нарушений глобальных ADR нет; `adr-0007` заведён и внесён в `docs/architecture/adr/README.md`.
## Качество кода
- Контракт never-raise выдержан на всех уровнях: outer loop, per-task, per-project, per-issue,
`_parse_grace_overrides`, `list_issues_by_state`, `_resolve_branch_via_db`, телеграм-нотификация.
- Идемпотентность: active-job guard в F-1 и F-2; самозатухание через обновление `updated_at`
после advance; `max_concurrency=1`. Подтверждено анализом — F-2 на approved/rejected всегда
меняет состояние (analysis approved-via-status всегда проходит; rollback всегда срабатывает),
поэтому петли спама нотификаций структурно не возникает.
- Защита от ложного матча в F-3 (только при единственной development-задаче repo).
- Docstrings содержательные на всех публичных функциях; тесты не тривиальные (мапятся на
TC-01…TC-21 из `04-test-plan.yaml`).
## Документация
Обновлена в этом же PR (AC-17 выполнен):
- `docs/architecture/README.md` — компонент Reconciler, раздел resilience, строка в таблице API
(`/queue` … + reconcile), footer-пометка. ✓
- `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md` — заведён. ✓
- `docs/architecture/adr/adr-0007-reconciler.md` + строка в `adr/README.md`. ✓
- `CHANGELOG.md` — запись в `[Unreleased]/Added`. ✓
- `docs/operations/INFRA.md` — kill-switch'и и env-карта (self-hosting). ✓
- `README.md` и `.env.example` — env-таблица `ORCH_RECONCILE_*`. ✓
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have
- Несоответствие статуса ADR: `06-adr/ADR-001` помечен `Статус: Proposed`, тогда как
`docs/architecture/adr/README.md` указывает `adr-0007` как `accepted`. Косметика —
привести к одному значению при следующем касании.
- `get_project_states(pid)` теоретически может вернуть словарь без ключей
`approved`/`rejected` при частичном резолве состояний проекта → `KeyError` в
`_reconcile_plane_project`. Сейчас изолировано per-project `try/except` (never-raise
держится, эффект — пропуск F-2 для проекта). Можно усилить `.get(...)`-доступом ради
явности; не блокер.

View File

@@ -0,0 +1,74 @@
---
type: test-report
work_item_id: ORCH-053
result: PASS
---
# Test Report — ORCH-053 (Sweeper потерянных webhook / reconciler)
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; asyncio mode=AUTO)
- Ветка: `feature/ORCH-053-sweeper-webhook-stuck-task`
- Дата: 2026-06-06
- Review verdict: APPROVED (`12-review.md`)
## Команда прогона
`python -m pytest tests/ -v --tb=short`**563 passed, 1 warning, 12.09s**
(warning — известный PydanticDeprecatedSince20 в `src/config.py`, не связан с ORCH-053).
## Результаты по тест-плану (`04-test-plan.yaml`)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | F-1: продвижение застрявшей development-задачи | test_reconciler::test_tc01_advances_stuck_development_task | PASS |
| TC-02 | Источник истины — гейт, advance только через advance_stage(finished_agent=None) | test_reconciler::test_tc02_advances_via_advance_stage_finished_agent_none | PASS |
| TC-03 | Активный job → задача пропускается | test_reconciler::test_tc03_active_job_skipped | PASS |
| TC-04 | Per-stage grace, граница age>=grace | test_reconciler::test_tc04_grace_boundary | PASS |
| TC-05 | grace_for_stage: overrides + невалидный JSON → дефолт | test_reconciler::test_tc05_grace_for_stage_overrides / _invalid_json_falls_back | PASS |
| TC-06 | Нет спама нотификаций на красном гейте | test_reconciler::test_tc06_red_gate_no_spam | PASS |
| TC-07 | Тишина при синхронности | test_reconciler::test_tc07_silence_when_in_sync | PASS |
| TC-08 | AC-16: F-1 не продвигает analysis | test_reconciler::test_tc08_analysis_not_advanced_by_f1 | PASS |
| TC-09 | Never-raise изолирует сбой одной задачи | test_reconciler::test_tc09_never_raise_isolates_failure | PASS |
| TC-10 | Kill-switch (reconcile_enabled / reconcile_plane_enabled) | test_reconciler::test_tc10_kill_switch_disables_gate / _plane_switch_mutes_only_f2 | PASS |
| TC-11 | F-2: In Progress без задачи → handle_status_start | test_reconciler_plane::test_tc11_in_progress_without_task_starts_pipeline | PASS |
| TC-12 | F-2: Approved → handle_verdict(approved=True) | test_reconciler_plane::test_tc12_approved_replays_verdict | PASS |
| TC-13 | F-2: Rejected → handle_verdict(approved=False) | test_reconciler_plane::test_tc13_rejected_replays_verdict | PASS |
| TC-14 | Идемпотентность F-2: активный job / в пределах grace | test_reconciler_plane::test_tc14_active_job_skips / test_tc14b_within_grace_skipped | PASS |
| TC-15 | AC-4 анти-дубль на создании (create_task_atomic) | test_reconciler_plane::test_tc15_create_task_atomic_no_duplicate | PASS |
| TC-16 | list_issues_by_state never-raise + пагинация/фильтр | test_reconciler_plane::test_tc16_list_issues_never_raises_on_error / _paginates_and_filters | PASS |
| TC-17 | F-2 опрашивает все проекты, резолвит state per-project | test_reconciler_plane::test_tc17_polls_all_projects_resolves_states_per_project | PASS |
| TC-18 | F-3: sha→branch БД-fallback однозначный матч | test_gitea_sha_resolve::test_tc18_db_fallback_unique_match_advances | PASS |
| TC-19 | F-3: неоднозначность → нет ложного матча | test_gitea_sha_resolve::test_tc19_db_fallback_ambiguous_no_match | PASS |
| TC-20 | F-4: лог-строка разблокировки + Telegram (вкл/выкл) | test_reconciler::test_tc20_unblock_logs_and_notifies / _no_telegram_when_disabled | PASS |
| TC-21 | Restart-safe daemon-поток: start/stop/идемпотентный start | test_reconciler::test_tc21_daemon_thread_lifecycle | PASS |
| TC-22 | Конфиг reconcile_* дефолты + env ORCH_ | test_config::test_reconcile_settings_defaults / _env_override | PASS |
| TC-23 | Регресс реестров STAGE_TRANSITIONS / QG_CHECKS не изменены | test_qg_registry_snapshot::test_tc20_qg_registry_unchanged / _qg_callables_unchanged / _stage_transitions_unchanged | PASS |
Все 23 TC покрыты тестами и зелёные (целевые файлы: 36 passed).
## Smoke test API (прод-контейнер 8500, только read-only GET, без касания состояния)
- `GET /health` → 200 `{"status":"ok","service":"orchestrator"}`
- `GET /status` → 200 (active_tasks отдаётся; видна задача id=44 ORCH-053 на стадии testing)
- `GET /queue` → 200 (counts/max_concurrency/resilience отдаются)
- Блок `reconcile` в `/queue` на проде ОТСУТСТВУЕТ — ожидаемо: прод работает на старом коде,
ORCH-053 ещё не задеплоен. В коде ветки блок реализован (`src/main.py:131`
`"reconcile": reconciler.status()`). Появится после deploy-staging/deploy.
## Покрытие Acceptance Criteria (`03-acceptance-criteria.md`)
AC-1…AC-16 — покрыты соответствующими TC (см. таблицу) и зелёные.
AC-17 (документация — golden source) — подтверждён на стадии review (APPROVED, секция
«Документация»): README.md архитектуры, ADR-001, adr-0007, CHANGELOG.md, INFRA.md обновлены.
## Вывод pytest (хвост)
```
======================= 563 passed, 1 warning in 12.09s ========================
```
Целевые файлы ORCH-053:
```
======================== 36 passed, 1 warning in 1.20s =========================
```
## Итог
**PASS** — полный регресс зелёный (563 passed), все 23 TC из тест-плана выполнены,
acceptance-criteria покрыты, smoke прод-API здоров. Задача готова к стадии `deploy-staging`.

View File

@@ -0,0 +1,7 @@
# Business Request: Self-deploy: retag берёт устаревший staging-образ (риск тихого регресса)
Work Item ID: ORCH-058
## Description
TBD

View File

@@ -0,0 +1,87 @@
# BRD — ORCH-058: Self-deploy retag берёт устаревший staging-образ (риск тихого регресса)
Work Item ID: ORCH-058
Тип: bug / техдолг инфраструктуры self-deploy
Источник: `docs/history/LESSONS_ORCH-036-selfdeploy.md` п.4 (самый опасный из 4 багов bootstrap ORCH-36)
## 1. Контекст
ORCH-36 сделал стадию `deploy` исполняемой для self-hosting репозитория `orchestrator`:
- Phase B (`src/self_deploy.py::build_deploy_command`) запускает детачед host-хук
`scripts/orchestrator-deploy-hook.sh` с параметром `SOURCE_IMAGE=orchestrator-orchestrator-staging`.
- Хук (шаг **2b**, BUILD-ONCE, ORCH-36 BR-6) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE`
**без `docker build`** — «прод получает ровно тот артефакт, что прошёл staging».
Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и провалидирован**. На практике этой
гарантии НЕТ.
## 2. Проблема (корень)
Конвейер **нигде не пересобирает** образ `orchestrator-orchestrator-staging` из текущего
кода (HEAD `main` / провалидированной ветки):
- Стадия `deploy-staging` запускает только `scripts/staging_check.py` (e2e-проверка)
против **уже работающего** контейнера `orchestrator-staging` (8501) — что бы в нём ни
крутилось. Сборка staging-образа — ручная операция (STAGING.md / ORCH-34), вне конвейера.
- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом.
Следствие (инцидент ORCH-36): staging-образ не пересобрали из нового `main`
`staging_check` прошёл против СТАРОГО кода → BUILD-ONCE retag промоутнул СТАРЫЙ образ в прод.
Деплой «зелёный» (`result=0`, health ok), но прод молча откатился на код 2-дневной давности:
пропал `deploy-finalizer` → задача не закрылась → бесконечная петля Phase B.
## 3. Почему это критично
> Это **самый опасный** из четырёх багов self-deploy: он **не падает**, а **тихо откатывает
> прод**. Зелёный гейт = ложный позитив. Орк обслуживает все проекты (enduro-trails) из одного
> прод-инстанса → тихий регресс инструмента = групповой инцидент для всех проектов.
Текущая защита (staging-гейт, merge-gate, health-check хука) НЕ ловит этот класс: все они
зелёные, потому что проверяют не тот артефакт, что уезжает в прод.
## 4. Бизнес-цель
Гарантировать инвариант: **в прод никогда не промоутится образ, не собранный из
провалидированного для данной задачи коммита; при невозможности это доказать — деплой
fail-fast (вердикт FAILED → откат на development), а не «тихо зелёный»**.
## 5. Объём (scope)
В объёме:
- Привязка артефакта (staging-образ → прод-retag) к провалидированному коммиту.
- Fail-fast при рассинхроне образа и кода (никаких тихих промоутов устаревшего).
- Условность как ORCH-35/36/43: реально только для `orchestrator`; прочие репо — no-op /
прежнее поведение.
- Контракт never-raise и fail-closed (на сомнении — не деплоить).
Вне объёма:
- Полный авто-approve прод-деплоя (ORCH-54).
- Изменение exit-code-контракта хука (0/1/2) и реестров `STAGE_TRANSITIONS` / `QG_CHECKS` как
набора стадий.
- Миграции схемы БД.
- Деплой/рестарт **прод**-контейнера `orchestrator` (8500) в рамках задачи.
## 6. Бизнес-требования (BR)
- **BR-1.** Образ, который BUILD-ONCE retag промоутит в прод, ДОЛЖЕН соответствовать коду,
провалидированному стадией `deploy-staging` для данной задачи (тот же git-коммит).
- **BR-2.** Если соответствие НЕ доказуемо (staging-образ собран не из провалидированного
коммита, либо провенанс невозможно прочесть) — деплой ОБЯЗАН fail-fast: вердикт `FAILED`,
штатный откат на `development` (контракт БАГ-8), без рестарта прода.
- **BR-3.** `staging_check.py` (e2e-валидация) ДОЛЖЕН прогоняться против артефакта,
соответствующего тому же провалидированному коммиту, что уедет в прод (нельзя валидировать
один образ, а катить другой).
- **BR-4.** Поведение условно: реально для `orchestrator`; для прочих репозиториев — no-op /
без регрессий прежнего синхронного деплоя.
- **BR-5.** Выбранное решение НЕ должно приводить к вечной блокировке деплоя (если механизм
свежести отсутствует — нужен путь, который доводит до зелёного, а не fail-fast'ит навсегда).
- **BR-6.** Контракт never-raise: сбой проверки свежести/провенанса не должен валить
stage_engine; на любом сомнении — fail-closed (трактуем как несоответствие).
- **BR-7.** Документация-голден-сорс: INFRA / DEPLOY_HOOK / STAGING / architecture README +
CHANGELOG обновляются в том же PR; решение оформляется ADR.
## 7. Связанные материалы
- `docs/history/LESSONS_ORCH-036-selfdeploy.md` (п.4 — корень)
- `docs/architecture/adr/adr-0007-executable-self-deploy.md`, `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`
- `src/self_deploy.py`, `scripts/orchestrator-deploy-hook.sh`, `src/config.py`
- `docs/operations/STAGING.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/INFRA.md`

View File

@@ -0,0 +1,126 @@
# ТЗ — ORCH-058: провенанс staging-образа перед BUILD-ONCE retag в прод
Work Item ID: ORCH-058
> Примечание: ТЗ фиксирует ТРЕБУЕМЫЕ изменения и точки в коде. **Выбор стратегии**
> (пересборка из HEAD `main` ПЕРЕД валидацией vs. fail-fast по провенансу образа, либо их
> комбинация) — решение **архитектора** (ADR в `06-adr/`). Ниже перечислены точки
> касания для обеих стратегий; архитектор выбирает и при необходимости сужает.
## 1. Инвариант, который нужно обеспечить
`INV-FRESH`: образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в прод,
собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если это
недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`, БАГ-8),
прод не трогается.
Якорь «провалидированного коммита» (architect фиксирует точно в ADR): SHA HEAD ветки задачи
после merge-gate rebase на `origin/main` (то, что валидировал `deploy-staging` + merge-gate).
## 2. Текущее поведение (что чинить)
| Место | Сейчас | Проблема |
|---|---|---|
| `scripts/orchestrator-deploy-hook.sh` шаг 2b | `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` без проверки происхождения образа | промоутит любой образ под именем `orchestrator-orchestrator-staging`, даже устаревший |
| Стадия `deploy-staging` (`.openclaw/agents/deployer.md` + `staging_check.py`) | гоняет e2e против уже запущенного 8501, не пересобирая образ | валидирует не тот артефакт, что уедет в прод |
| `src/self_deploy.py::build_deploy_command` | передаёт `SOURCE_IMAGE`, `TARGET_*`, `COMPOSE_PROFILE`, `PREV_IMAGE_FILE`; провенанс/SHA не передаёт | хук не знает, какой коммит ожидать |
| `Dockerfile` | без OCI-лейбла `revision`/git-SHA | у образа нет машиночитаемого происхождения для проверки |
## 3. Задействованные модули `src/` и файлы
- `src/self_deploy.py` — основной (provenance-helpers + проброс ожидаемого SHA в команду хука).
- `src/config.py` — новые настройки (`ORCH_`-префикс обязателен, урок ORCH-36 п.2).
- `scripts/orchestrator-deploy-hook.sh` — fail-fast по провенансу и/или пересборка перед retag.
- `Dockerfile` — лейбл происхождения образа (для стратегии «провенанс по labels/sha»).
- `src/qg/checks.py` — опц. новый детерминированный под-чек свежести (если стратегия «гейт»).
- `src/stage_engine.py` — опц. точка вызова под-чека на ребре `deploy-staging → deploy`
(рядом с merge-gate, строки ~262288). **Реестр `STAGE_TRANSITIONS` не меняется.**
- `.openclaw/agents/deployer.md` — шаги стадии `deploy-staging` (если выбран rebuild-перед-валидацией).
- `docker-compose.yml` — опц. build-args/labels для staging-сервиса (если стратегия rebuild).
## 4. Требуемые изменения — стратегия A (пересборка из HEAD main перед валидацией)
A1. Перед прогоном `staging_check.py` стадия `deploy-staging` для `orchestrator` пересобирает
образ `orchestrator-orchestrator-staging` из провалидированного коммита (worktree ветки
после merge-gate rebase) и пересоздаёт контейнер 8501 на свежем образе.
A2. `staging_check.py` гоняется против свежего контейнера; на `SUCCESS` ровно ЭТОТ образ
становится `SOURCE_IMAGE` для прод-retag (loop closed).
A3. Детерминированно (без LLM в критическом пути): сборку/recreate выполняет код стадии или
host-хук в staging-режиме, не агент-деплойер «руками».
A4. Безопасность: операция трогает ТОЛЬКО staging (8501), НИКОГДА прод (8500).
## 5. Требуемые изменения — стратегия B (fail-fast по провенансу образа)
B1. `Dockerfile`: добавить лейбл происхождения, напр.
`LABEL org.opencontainers.image.revision=$GIT_SHA` через `ARG GIT_SHA` (build-arg).
B2. Сборка staging-образа (ручная или из стратегии A) проставляет `GIT_SHA` = коммит сборки.
B3. `src/self_deploy.py::build_deploy_command`: вычислить ожидаемый SHA провалидированного
коммита и пробросить в команду хука новым env (напр. `EXPECTED_REVISION=<sha>`).
Новый pure-helper, напр. `expected_revision(repo, branch) -> str` (never-raise).
B4. `scripts/orchestrator-deploy-hook.sh` шаг 2b: ПЕРЕД `docker tag` прочитать лейбл
`$SOURCE_IMAGE` (`docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'`)
и сравнить с `$EXPECTED_REVISION`. Несовпадение / пустой лейбл / пустой ожидаемый SHA →
`log` + `exit 1` (fail-fast). Поведение обратносовместимо: при незаданном
`EXPECTED_REVISION` — текущее поведение (без проверки), чтобы не сломать не-self репо.
B5. exit 1 хука уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется),
Phase C пишет `14-deploy-log.md` `deploy_status: FAILED` → откат на `development` (БАГ-8).
## 6. Требуемые изменения — опц. под-гейт (если архитектор выберет gate-side для B)
- Новый детерминированный (без LLM) под-чек, напр. `check_staging_image_fresh`, по образцу
`check_branch_mergeable` (ORCH-043): pure verdict-logic + условность (`self_deploy_applies`
/ `is_self_hosting_repo`), never-raise, для прочих репо → `(True, "N/A")`.
- Вызов на ребре `deploy-staging → deploy` ПЕРЕД Phase A (рядом с merge-gate, `stage_engine`
~268288). FAIL → откат на `development` (как merge-gate). Реестр стадий неизменен —
это под-гейт ребра, не новая стадия.
- Если выбран чисто хуковый fail-fast (раздел 5) — под-гейт не нужен.
## 7. Изменения API
Нет. Эндпоинты (`/health`, `/status`, `/queue`, `/webhook/*`) не меняются. Опц.: в снимок
`GET /queue` можно добавить диагностическое поле о свежести образа — НЕ обязательно.
## 8. Изменения схемы БД
Нет. Состояние deploy — sentinel-файлы (`.deploy-state-<repo>/<wi>/`, ORCH-36). Миграции
запрещены (как ORCH-36/43/53).
## 9. Конфигурация (`src/config.py`, ВСЕ с префиксом `ORCH_`)
Кандидаты (architect финализирует имена и дефолты):
- `image_freshness_enabled: bool = True` — kill-switch проверки (поэтапный раскат).
- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как `self_deploy_repos`).
- (для стратегии B) проброс `EXPECTED_REVISION` строится в `build_deploy_command`, отдельной
настройки может не требоваться.
- (для стратегии A) при необходимости — имя/тег staging-образа уже есть
(`deploy_prod_source_image`).
Урок ORCH-36 п.2: любая настройка, читаемая pydantic Settings, ОБЯЗАНА иметь префикс `ORCH_`.
## 10. Новые QG checks (если применимо)
- Опц. `check_staging_image_fresh` (см. §6) — добавить в реестр `QG_CHECKS` и в
snapshot-тест реестра (`tests/test_qg_registry_snapshot.py`). Только если выбран gate-side.
## 11. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR)
- `06-adr/ADR-001-<slug>.md` — выбор стратегии (A / B / A+B), якорь «провалидированного
коммита», точки fail-fast, условность, never-raise, отсутствие deadlock (BR-5).
- `docs/operations/DEPLOY_HOOK.md` — описание провенанс-проверки / пересборки и новых env.
- `docs/operations/STAGING.md` — как и когда пересобирается staging-образ в конвейере.
- `docs/operations/INFRA.md` — обновить топологию/риск self-deploy (закрыт п.4 каскада).
- `docs/architecture/README.md` — секция ORCH-36/58 (свежесть артефакта в BUILD-ONCE).
- `CHANGELOG.md` — запись ORCH-058.
- При выборе стратегии A: bootstrap-чеклист (урок ORCH-36 «сквозной»: реальный staging-прогон
до мержа).
## 12. Инварианты / ограничения (self-hosting safety)
- Никогда не рестартовать/ронять прод 8500 в рамках задачи (CLAUDE.md). Любая сборка/recreate —
только staging 8501.
- Никогда не пушить/форс-пушить `main` (как merge-gate).
- Контракты НЕ меняются: exit-code хука (0/1/2), `map_exit_code_to_status`,
`check_deploy_status`/`_parse_deploy_status`, БАГ-8 rollback, terminal-sync, merge-gate.
- Fail-closed: на любом сомнении (нет лейбла, нет ожидаемого SHA, ошибка inspect) —
трактовать как несоответствие → FAILED, никогда не промоутить «на авось».
- never-raise: helpers и под-чек не должны пробрасывать исключение в stage_engine.

View File

@@ -0,0 +1,71 @@
# Критерии приёмки — ORCH-058
Work Item ID: ORCH-058
Критерии сформулированы вокруг инварианта `INV-FRESH` и **не зависят** от выбранной
архитектором стратегии (A — пересборка, B — fail-fast по провенансу, A+B). Каждый — с
чётким условием PASS/FAIL.
## AC-1 — Соответствие артефакта коду (центральный инвариант)
- PASS: образ, который BUILD-ONCE retag промоутит в прод (`SOURCE_IMAGE`), доказуемо собран
из коммита, провалидированного стадией `deploy-staging` для этой задачи.
- FAIL: в прод может уехать образ, собранный не из провалидированного коммита.
## AC-2 — Fail-fast при рассинхроне (никаких тихих зелёных)
- PASS: если staging-образ собран НЕ из провалидированного коммита (или провенанс нечитаем),
деплой завершается `deploy_status: FAILED` и откатом на `development` (БАГ-8); прод НЕ
рестартуется на устаревший образ.
- FAIL: при рассинхроне деплой завершается `SUCCESS` / «зелёным», прод тихо откатывается.
## AC-3 — Fail-closed на сомнении
- PASS: при отсутствии лейбла происхождения, пустом ожидаемом SHA, ошибке `docker image
inspect` или любой неоднозначности — трактуется как несоответствие → FAILED (никогда не
промоутится «на авось»).
- FAIL: сомнительный/непроверяемый случай трактуется как «свежий» и промоутится.
## AC-4 — Валидация и промоут — один и тот же артефакт
- PASS: `staging_check.py` прогоняется против образа/контейнера, соответствующего тому же
провалидированному коммиту, который затем уезжает в прод.
- FAIL: валидируется один образ, а в прод retag'ается другой.
## AC-5 — Условность (self-hosting only)
- PASS: проверка/пересборка реальна только для `orchestrator` (и репо из `image_freshness_repos`,
если задан); для прочих репо — no-op, синхронный деплой не-self репо без регрессий.
- FAIL: логика срабатывает для не-self репозиториев или ломает их деплой.
## AC-6 — Никакого deadlock деплоя (BR-5)
- PASS: при штатном прогоне (staging-образ корректно отражает провалидированный коммит)
деплой доходит до `SUCCESS` и `deploy → done`; механизм свежести не блокирует валидный
деплой навсегда.
- FAIL: валидный деплой вечно fail-fast'ится / задача зависает на `deploy`.
## AC-7 — Контракты не изменены
- PASS: `STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8 rollback,
terminal-sync, merge-gate — без изменений; схема БД без миграций.
- FAIL: затронут любой из перечисленных контрактов или добавлена миграция БД.
## AC-8 — never-raise
- PASS: сбой проверки свежести/провенанса (битый образ, ssh/docker error, отсутствующий
worktree) не пробрасывает исключение в `stage_engine`; возвращается безопасный вердикт.
- FAIL: исключение из новой логики всплывает и валит обработку стадии.
## AC-9 — Self-hosting safety
- PASS: новая логика НЕ рестартует/не роняет прод-контейнер `orchestrator` (8500) и не
пушит/форс-пушит `main`; любые сборки/recreate — только staging (8501).
- FAIL: нарушено любое из ограничений выше.
## AC-10 — Конфигурация и kill-switch
- PASS: новые настройки имеют префикс `ORCH_`; есть kill-switch (напр. `image_freshness_enabled`)
для поэтапного раската; при выключенном флаге — прежнее поведение.
- FAIL: настройка без `ORCH_`-префикса (не читается pydantic) или нет способа отключить.
## AC-11 — Документация (golden source)
- PASS: в том же PR обновлены DEPLOY_HOOK.md, STAGING.md, INFRA.md, architecture/README.md,
CHANGELOG.md и заведён ADR `06-adr/ADR-001-*`.
- FAIL: функционал изменён, документация/ADR не обновлены (→ reviewer REQUEST_CHANGES).
## AC-12 — Тесты зелёные
- PASS: `pytest tests/ -q` зелёный, включая новые тесты из `04-test-plan.yaml` и
snapshot-тест реестра QG (если добавлен под-чек).
- FAIL: любой тест из плана красный или регрессия существующих.

View File

@@ -0,0 +1,124 @@
work_item: ORCH-058
description: >
Провенанс staging-образа перед BUILD-ONCE retag в прод. Тесты покрывают инвариант
INV-FRESH: соответствие промоутируемого образа провалидированному коммиту, fail-fast
и fail-closed при рассинхроне, условность self-hosting, never-raise, неизменность
контрактов. Часть кейсов помечена strategy-зависимыми (A=пересборка, B=fail-fast по
провенансу) — финальный набор подтверждает архитектор в ADR; пишутся тесты для
выбранной стратегии.
tests:
- id: TC-01
type: unit
description: >
Pure provenance-verdict: SHA образа == ожидаемый SHA -> свежий (PASS).
Совпадающие revision дают вердикт "соответствует".
module: tests/test_image_freshness.py
expected: PASS
- id: TC-02
type: unit
description: >
Pure provenance-verdict: SHA образа != ожидаемый SHA -> НЕ свежий ->
вердикт несоответствия (вход для fail-fast).
module: tests/test_image_freshness.py
expected: PASS
- id: TC-03
type: unit
description: >
Fail-closed: пустой/отсутствующий лейбл образа ИЛИ пустой ожидаемый SHA ->
трактуется как несоответствие (никогда не "свежий по умолчанию").
module: tests/test_image_freshness.py
expected: PASS
- id: TC-04
type: unit
description: >
never-raise: provenance-helper при docker/ssh/inspect ошибке или отсутствующем
worktree возвращает безопасный вердикт (несоответствие), не пробрасывает исключение.
module: tests/test_image_freshness.py
expected: PASS
- id: TC-05
type: unit
description: >
Условность: для не-self репозитория проверка свежести = no-op (True/"N/A");
для orchestrator (или репо из image_freshness_repos) — реальна.
module: tests/test_image_freshness.py
expected: PASS
- id: TC-06
type: unit
description: >
[Стратегия B] build_deploy_command пробрасывает EXPECTED_REVISION=<sha>
в remote-команду хука рядом с SOURCE_IMAGE; формат env корректен (shlex-quote).
module: tests/test_deploy_build_once.py
expected: PASS
- id: TC-07
type: unit
description: >
[Стратегия B] Хук содержит ветку fail-fast: при заданном EXPECTED_REVISION и
несовпадении revision лейбла SOURCE_IMAGE -> exit 1 ПЕРЕД docker tag; при пустом
EXPECTED_REVISION -> обратносовместимое поведение (без проверки). Статическая
проверка текста scripts/orchestrator-deploy-hook.sh (паттерн test_deploy_build_once).
module: tests/test_deploy_hook_provenance.py
expected: PASS
- id: TC-08
type: unit
description: >
[Стратегия B] Dockerfile объявляет ARG GIT_SHA и LABEL
org.opencontainers.image.revision=$GIT_SHA (статическая проверка текста Dockerfile).
module: tests/test_deploy_hook_provenance.py
expected: PASS
- id: TC-09
type: unit
description: >
Маппинг контракта: exit 1 хука (fail-fast по провенансу) ->
map_exit_code_to_status == "FAILED" (контракт ORCH-36 не изменён).
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-10
type: integration
description: >
Stale-образ -> fail-fast end-to-end: на ребре deploy-staging->deploy при
несоответствии образа Phase B/хук дают FAILED -> advance_stage откатывает на
development (БАГ-8), прод не "зелёный". Прод-рестарт замокан.
module: tests/test_stage_engine.py
expected: PASS
- id: TC-11
type: integration
description: >
Свежий образ -> happy path: соответствие revision -> деплой доходит до SUCCESS и
deploy->done; механизм свежести не блокирует валидный деплой (anti-deadlock, AC-6).
Host-процесс/хук замокан.
module: tests/test_stage_engine.py
expected: PASS
- id: TC-12
type: unit
description: >
[Если выбран gate-side] check_staging_image_fresh зарегистрирован в QG_CHECKS;
snapshot-тест реестра обновлён и зелёный.
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-13
type: unit
description: >
Конфигурация: новые настройки (image_freshness_enabled / image_freshness_repos)
читаются с префиксом ORCH_ и имеют дефолты; kill-switch off -> прежнее поведение.
module: tests/test_config.py
expected: PASS
- id: TC-14
type: unit
description: >
Регрессия контрактов: STAGE_TRANSITIONS (набор стадий) и exit-code-контракт хука
(0/1/2) не изменены существующими правками.
module: tests/test_stages.py
expected: PASS

View File

@@ -0,0 +1,209 @@
# ADR-001 (ORCH-058): Провенанс staging-образа перед BUILD-ONCE retag в прод
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-058-self-deploy-retag-staging`.
Метка: `arch:major-change` (новая deploy-safety модель + новый QG + новый режим хука).
## Контекст
ORCH-36 сделал стадию `deploy` исполняемой для self-hosting (`orchestrator`): Phase B
(`self_deploy.build_deploy_command`) запускает детачед host-хук, который шагом **2b**
(BUILD-ONCE) делает `docker tag $SOURCE_IMAGE → $TARGET_IMAGE` **без `docker build`**
«прод получает ровно тот артефакт, что прошёл staging».
Дизайн-предпосылка BUILD-ONCE: **staging-образ свеж и собран из провалидированного кода**.
На практике этой гарантии НЕТ (BRD §2):
- Стадия `deploy-staging` запускает только `scripts/staging_check.py` против **уже
работающего** контейнера 8501 — что бы в нём ни крутилось. Пересборка staging-образа —
ручная операция (STAGING.md / ORCH-34), вне конвейера.
- Между «образ собран» и «retag в прод» нет провенанс-связи с провалидированным коммитом.
Инцидент (LESSONS_ORCH-036 п.4 — **самый опасный** из 4 багов bootstrap): staging-образ
не пересобрали из нового `main``staging_check` прошёл против СТАРОГО кода → BUILD-ONCE
retag промоутнул СТАРЫЙ образ в прод. Деплой «зелёный» (`result=0`, health ok), но прод
**молча откатился** на код 2-дневной давности. Орк обслуживает все проекты из одного
прод-инстанса → тихий регресс инструмента = групповой инцидент.
Текущая защита (staging-гейт, merge-gate, health-check хука) этот класс НЕ ловит: все
гейты зелёные, потому что проверяют **не тот артефакт**, что уезжает в прод.
## Инвариант, который нужно обеспечить
`INV-FRESH` (ТЗ §1): образ, передаваемый хуку как `SOURCE_IMAGE` для BUILD-ONCE retag в
прод, собран из ТОГО ЖЕ git-коммита, что прошёл `deploy-staging` для этой задачи. Если
это недоказуемо — деплой fail-fast (`deploy_status: FAILED` → откат на `development`,
БАГ-8), прод не трогается.
### Якорь «провалидированного коммита»
**SHA = `git rev-parse HEAD` в worktree ветки задачи ПОСЛЕ merge-gate** (т.е. после
возможного `auto_rebase_onto_main` + `push --force-with-lease`). Это ровно тот tree,
который merge-gate ре-тестировал зелёным и который сольётся в `main`. Один helper
`validated_revision(repo, branch)` (never-raise) вычисляет SHA и служит ЕДИНСТВЕННЫМ
источником и для штампа сборки (Стратегия A), и для ожидаемого ревижна (Стратегия B) —
два потребителя одного якоря не могут разойтись.
## Решение: A + B (defense in depth)
Ни одна стратегия по отдельности не закрывает задачу:
- **B в одиночку** (fail-fast по провенансу) делает тихий промоут структурно невозможным,
НО если staging-образ устарел — fail-fast'ит **навсегда** (нет пути к зелёному без
ручной пересборки) → нарушает BR-5 / AC-6 (deadlock), воспроизводит ровно тот
bootstrap-разрыв, который мы устраняем.
- **A в одиночку** (пересборка из провалидированного коммита) закрывает петлю «валидируем =
промоутим», НО не имеет утверждения В МОМЕНТ retag: гонка/отключение/сбой пересборки
снова даст тихий промоут.
Поэтому берём **обе**, как взаимодополняющие слои:
### Стратегия A — пересборка staging-образа из провалидированного коммита (liveness, AC-4/AC-6)
Для self-hosting на ребре `deploy-staging → deploy`, **после merge-gate** (когда
валидированный HEAD финализирован) и **до Phase A**, детерминированный код:
1. Вычисляет `sha = validated_revision(repo, branch)`.
2. Пересобирает `orchestrator-orchestrator-staging` из **worktree ветки** (build-context =
валидированный tree) с `--build-arg GIT_SHA=<sha>` и пересоздаёт контейнер 8501 на
свежем образе (`--no-build`).
3. Прогоняет `staging_check.py --mode stub` против свежего 8501.
Результат: ровно ЭТОТ образ (с лейблом `revision=<sha>`) становится `SOURCE_IMAGE` для
прод-retag → петля замкнута, валидируем и промоутим один артефакт (AC-4). Пересборка/
recreate трогают **ТОЛЬКО staging (8501)**, НИКОГДА прод (8500) (AC-9).
Исполнение — через host (ssh, синхронно): docker CLI / compose доступны на ХОСТЕ, не в
контейнере (Dockerfile ставит только `openssh-client git`; staging_check уже гоняется
`docker exec`-ом на хосте). Новый режим хука `--build-staging` (см. ниже) выполняет сборку
и recreate. Синхронный ssh достаточен — рестарт staging не убивает прод-worker (в отличие
от Phase B, где нужен detached + finalizer).
Реализуется как **детерминированный QG-под-чек `check_staging_image_fresh`** (по образцу
`check_branch_mergeable`, ORCH-043): pure-условность + never-raise; для прочих репо →
`(True, "N/A")`. Регистрируется в `QG_CHECKS` и в `tests/test_qg_registry_snapshot.py`.
Вызов — на ребре через `_handle_image_freshness(...)` в `stage_engine` (рядом с
`_handle_merge_gate`, ПОСЛЕ него, ДО Phase A). FAIL → откат на `development` + release
merge-lease (как merge-gate). **`STAGE_TRANSITIONS` (набор стадий) НЕ меняется** — это
под-гейт ребра.
### Стратегия B — fail-closed провенанс-guard в хуке (safety, AC-1/AC-2/AC-3)
1. **`Dockerfile`**: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`.
Без build-arg лейбл пустой → fail-closed на стороне B (см. ниже).
2. **`build_deploy_command`**: вычисляет `EXPECTED_REVISION = validated_revision(repo,
branch)` и пробрасывает в env команды хука.
3. **`orchestrator-deploy-hook.sh` шаг 2b** — ПЕРЕД `docker tag`:
- читает лейбл `SOURCE_IMAGE`:
`docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$SOURCE_IMAGE"`;
- сравнивает с `$EXPECTED_REVISION`;
- несовпадение / пустой лейбл / пустой `EXPECTED_REVISION` / ошибка inspect →
`log` + `exit 1` (**fail-closed**, никогда не промоутить «на авось»).
- **Обратная совместимость:** при НЕзаданном `EXPECTED_REVISION` — текущее поведение
(проверка пропускается), чтобы не сломать не-self репо и legacy-вызовы.
4. `exit 1` уже маппится `map_exit_code_to_status → FAILED` (контракт не меняется), Phase C
пишет `deploy_status: FAILED` → откат на `development` (БАГ-8). Прод не рестартуется на
устаревший образ — guard срабатывает ДО `docker tag`/restart.
### Новый режим хука `--build-staging` (для Стратегии A)
`orchestrator-deploy-hook.sh --build-staging` (env: `GIT_SHA`, `BUILD_CONTEXT` = host-путь
worktree, `TARGET_IMAGE=orchestrator-orchestrator-staging`, `TARGET_SERVICE`,
`COMPOSE_PROFILE=staging`, `TARGET_PORT=8501`):
`docker build --build-arg GIT_SHA=<sha> -t <TARGET_IMAGE> <BUILD_CONTEXT>` →
`docker compose --profile staging up -d --no-build orchestrator-staging` → health 8501.
Тот же exit-code-контракт (0=ok). Дефолты режима — STAGING-safe (как у `--deploy`).
Host-путь build-context выводится из container-пути worktree заменой
`repos_dir → host_repos_dir` (как `host_state_dir` в `self_deploy.py`); требуется
производный helper host-worktree-пути (или новая настройка `ORCH_HOST_WORKTREES_DIR`).
## Конфигурация (`src/config.py`, все с префиксом `ORCH_` — урок ORCH-36 п.2)
- `image_freshness_enabled: bool = True` — **единый** kill-switch ВСЕЙ фичи (A и B вместе).
`False` → ни пересборки, ни проброса `EXPECTED_REVISION` → поведение ровно как ORCH-36
(BUILD-ONCE без guard). A и B включаются/выключаются **как одно целое**, чтобы не было
опасной полу-конфигурации «B без A» (вечный fail-fast).
- `image_freshness_repos: str = ""` — CSV; пусто → только self-hosting (как
`self_deploy_repos` / `merge_gate_repos`).
> **Инвариант конфигурации (AC-6):** B активен ТОЛЬКО когда активен A. По умолчанию
> (`image_freshness_enabled=True`) валидный деплой всегда доходит до зелёного (A пересобирает
> → лейбл == EXPECTED → B пропускает). Полное выключение → legacy ORCH-36 поведение.
## Порядок на ребре `deploy-staging → deploy` (self-hosting)
1. `check_staging_status` (существующий) — первичный staging-вердикт агента (smoke,
что staging-инфра жива).
2. merge-gate `check_branch_mergeable` (существующий) — финализирует валидированный HEAD
(rebase если позади, ре-тест зелёный, lease HELD). DEFER на busy-lock → возврат без
пересборки.
3. **`check_staging_image_fresh` (НОВЫЙ, Стратегия A)** — пересборка из валидированного
HEAD + recreate 8501 + `staging_check`. FAIL → откат на `development` + release lease.
4. Phase A (существующий) → запрос approve.
5. Phase B (human Approved) → `build_deploy_command` с `EXPECTED_REVISION` → хук-guard (B)
→ BUILD-ONCE retag только при совпадении → restart прод → Phase C finalizer.
> Двойной прогон `staging_check` (агент на стадии + код на шаге 3) — **намеренный**: первый
> валидирует УЖЕ работающий (потенциально устаревший) 8501 как soft pre-check; авторитетный
> — шаг 3 против СВЕЖЕГО образа, который и уедет в прод. `--mode stub` быстр и без LLM-трат.
## Контракты, которые НЕ меняются (AC-7)
`STAGE_TRANSITIONS` (набор стадий), exit-code-контракт хука (0/1/2),
`map_exit_code_to_status`, `check_deploy_status` / `_parse_deploy_status` (frontmatter-only),
БАГ-8 rollback, terminal-sync `deploy → done`, merge-gate (ORCH-43), Phase A/B/C ORCH-36.
**Схема БД — без миграций** (состояние свежести не персистится в БД; провенанс живёт в
лейбле образа). Добавление `check_staging_image_fresh` в `QG_CHECKS` — ожидаемое расширение
реестра (ТЗ §10), не входит в замороженный список AC-7.
## Last-line-of-defence / fail-closed (AC-2/AC-3)
Даже если A отключена/проиграла гонку/сбойнула — **B (хук-guard) делает тихий промоут
устаревшего образа структурно невозможным**: рассинхрон лейбла и `EXPECTED_REVISION` →
`exit 1` ДО retag → FAILED → откат. На любом сомнении (нет лейбла, пустой ожидаемый SHA,
ошибка inspect) — трактуется как несоответствие. Прод никогда не трогается «на авось».
## never-raise (AC-8)
`validated_revision`, `rebuild_staging_image`, `check_staging_image_fresh`,
`build_deploy_command` (проброс EXPECTED) — все защищены try/except, любая ошибка → безопасный
вердикт (для A-под-чека: `(False, reason)` с release lease; пустой `EXPECTED_REVISION` на
сомнении → B fail-closed). Исключение никогда не всплывает в `stage_engine`.
## Последствия
**Плюсы**
- Класс «тихого регресса прод» закрыт структурно (B), а валидный деплой всегда доходит до
зелёного (A) — bootstrap-разрыв «ручная пересборка staging» устранён.
- Валидируем и промоутим один и тот же артефакт (AC-4); провенанс машиночитаем (лейбл).
- Единый kill-switch, поэтапный раскат, условность только для self-hosting — без регрессий
для не-self репо.
**Минусы / ограничения**
- Латентность ребра растёт: +`docker build` staging + recreate 8501 + повторный
`staging_check` перед Phase A. Приемлемо (выполняется в monitor-треде, как merge-gate
re-test; bounded timeouts).
- `staging_check` гоняется дважды (soft pre-check агента + авторитетный код) — осознанная
плата за AC-4. Возможная будущая оптимизация: облегчить шаг 3 до health+revision-smoke,
если merge-gate re-test признать достаточным для кода.
- Требуется host-доступ к `docker build`/`compose` под slin (как для `--deploy`) и writable
build-context (worktree) — заложено инфра-требованиями (07).
- Новая под-компонента (QG `check_staging_image_fresh` + режим хука `--build-staging`) →
`arch:major-change`.
## Альтернативы (отклонены)
- **Только B.** Deadlock без авто-пересборки (BR-5/AC-6). ❌
- **Только A.** Нет утверждения в момент retag → гонка/отключение снова даёт тихий промоут
(AC-2/AC-3). ❌
- **Rebuild в хуке на Phase B (прод-сторона).** Уничтожает BUILD-ONCE (прод-rebuild) и
промоутит образ, который staging-e2e никогда не валидировал. ❌
- **Rebuild напрямую из контейнера через docker.sock.** В образе нет docker CLI/compose;
staging-операции и так host-side (ssh). ❌
## Связанные ADR
Глобальный: `docs/architecture/adr/adr-0008-staging-image-provenance.md`.
`adr-0007-executable-self-deploy` (ORCH-36, BUILD-ONCE), `adr-0006-merge-gate` (ORCH-43,
образец edge-под-гейта), `adr-0003-staging-gate` (ORCH-35, условность), `adr-0005`
(run-as-host-uid).

View File

@@ -0,0 +1,71 @@
# Инфра-требования — ORCH-058
Work Item ID: ORCH-058
Топология не меняется (тот же сервер mva154, те же контейнеры 8500/8501, общая БД). Меняется
**что делает self-deploy на ребре `deploy-staging → deploy`** для self-hosting. Полная
топология/риски — `docs/operations/INFRA.md` (обновить в том же PR).
## IR-1. Host-сборка staging-образа (Стратегия A)
Шаг свежести пересобирает `orchestrator-orchestrator-staging` на ХОСТЕ (docker CLI/compose
есть на хосте, НЕ в контейнере — образ ставит только `openssh-client git`). Требуется:
- Рабочий ssh `slin@127.0.0.1` (уже есть, ORCH-36 / LESSONS п.12: passwd-запись uid 1000,
ключ смонтирован, `ORCH_DEPLOY_*` префиксы).
- На хосте под `slin` доступны `docker build` и `docker compose --profile staging`
(recreate 8501). Группа docker (`group_add: "999"` / host-доступ к `docker.sock`) — уже
настроено.
- **Build-context = host-путь worktree** валидированной ветки
(`/home/slin/repos/_wt/<repo>/<branch-slug>`), читаемый под `slin`. Worktree уже
создаётся launcher'ом/merge-gate под slin (ADR-0005 run-as-host-uid) — права ок.
- Лог-директория хука writable под slin (`/var/log/orchestrator`, LESSONS п.3) — уже.
## IR-2. Вывод host-пути worktree
В контейнере worktree виден как `ORCH_WORKTREES_DIR=/repos/_wt/...`; на хосте — как
`/home/slin/repos/_wt/...`. Маппинг = замена `repos_dir → host_repos_dir` (как
`self_deploy.host_state_dir`). Реализация: производный helper host-worktree-пути, либо новая
настройка `ORCH_HOST_WORKTREES_DIR` (дефолт `/home/slin/repos/_wt`). Без неё — деривация из
`host_repos_dir`.
## IR-3. OCI-лейбл происхождения (Стратегия B)
`Dockerfile`: `ARG GIT_SHA` + `LABEL org.opencontainers.image.revision=$GIT_SHA`. Сборки БЕЗ
build-arg (ручные/legacy) дают пустой лейбл → B fail-closed (это by design, не регрессия:
прод-retag без доказуемого провенанса должен падать). Любой существующий способ сборки прод/
staging-образа (CI, ручной) при включённой фиче ОБЯЗАН передавать `--build-arg GIT_SHA=<sha>`,
иначе деплой задачи fail-fast'нется на guard. Шаг A это делает автоматически.
## IR-4. ssh-режим хука `--build-staging`
Новый режим `orchestrator-deploy-hook.sh --build-staging` запускается синхронно (рестарт
staging безопасен, detached/finalizer не нужны — в отличие от Phase B прод). Дефолты режима —
STAGING-safe (`TARGET_PORT=8501`, `--profile staging`). Прод (8500) этим режимом НЕ
затрагивается.
## IR-5. Конфигурация (env, префикс `ORCH_`)
- `ORCH_IMAGE_FRESHNESS_ENABLED` (дефолт true) — единый kill-switch A+B.
- `ORCH_IMAGE_FRESHNESS_REPOS` (дефолт пусто → self-hosting).
- (опц.) `ORCH_HOST_WORKTREES_DIR` (дефолт `/home/slin/repos/_wt`).
`EXPECTED_REVISION` для хука строится в `build_deploy_command` — отдельной настройки не
требует. `deploy_prod_source_image` (= `orchestrator-orchestrator-staging`) переиспользуется.
## IR-6. Безопасность self-hosting (инварианты)
- Любые `docker build` / `compose up` / recreate — ТОЛЬКО staging (8501); прод (8500) не
рестартуется в рамках шага свежести.
- `main` не пушится; force-only — `--force-with-lease` на ветку задачи (merge-gate, без
изменений). Шаг A не пушит ничего (только локальный `docker build`).
- B-guard срабатывает ДО `docker tag`/restart — прод не трогается на сомнении.
## IR-7. Bootstrap-чеклист (урок ORCH-36 «сквозной»)
Перед мержем ORCH-058 — **реальный** прогон в staging-петле (не только бумажные гейты):
сборка staging из worktree с GIT_SHA → лейбл присутствует
(`docker image inspect ... revision`) → recreate 8501 → `staging_check` зелёный →
`build_deploy_command` отдаёт непустой `EXPECTED_REVISION` → хук-guard пропускает при
совпадении и `exit 1` при подмене `SOURCE_IMAGE` на устаревший. Зафиксировать в bootstrap-
заметке (как LESSONS_ORCH-036).

View File

@@ -0,0 +1,16 @@
# Технические риски — ORCH-058
Work Item ID: ORCH-058
| ID | Риск | Вероятность / Влияние | Митигация |
|----|------|----------------------|-----------|
| R-1 | **Полу-конфигурация «B без A»** → вечный fail-fast деплоя (B падает, никто не пересобирает) | Низк. / Высок. (deadlock, BR-5) | Единый kill-switch `image_freshness_enabled` включает/выключает A и B **как целое**; раздельных флагов A/B нет. Дефолт — оба включены. AC-6. |
| R-2 | **Рассинхрон якоря**: merge-gate делает rebase ПОСЛЕ того, как агент прогнал staging_check → HEAD изменился | Сред. / Сред. | Якорь берётся ПОСЛЕ merge-gate; шаг A пересобирает из post-rebase HEAD; авторитетный staging_check — против свежего образа. Pre-check агента — soft. |
| R-3 | **Гонка**: между пересборкой A и Phase B human-approve worktree HEAD сместился | Низк. / Высок. | B сверяет лейбл образа с `EXPECTED_REVISION`=validated_revision на момент Phase B; рассинхрон → fail-closed `exit 1`, прод не трогается. AC-2/AC-3. |
| R-4 | **Пустой лейбл** (ручная/legacy/CI-сборка без `--build-arg GIT_SHA`) | Сред. / Высок. | Fail-closed: пустой лейбл → несоответствие → `exit 1`. By design. Шаг A всегда передаёт GIT_SHA. IR-3 фиксирует требование к любым сборкам. |
| R-5 | **Латентность ребра**: +docker build staging +recreate +повторный staging_check перед approve | Высок. / Низк. | Bounded timeouts; выполняется в monitor-треде (как merge-gate re-test). `staging_check --mode stub` без LLM-трат. Приемлемо. |
| R-6 | **Сборка/recreate случайно затронет прод (8500)** | Низк. / Критич. | Режим хука `--build-staging` со STAGING-safe дефолтами (8501, `--profile staging`); код шага A никогда не передаёт прод-параметры. AC-9. Тест-инвариант: цель != прод. |
| R-7 | **docker build на хосте падает** (нет места, недоступен daemon, битый worktree) | Низк. / Сред. | never-raise: `check_staging_image_fresh``(False, reason)` + release lease → откат на `development` (не зависание, не тихий промоут). AC-8. |
| R-8 | **Двойной staging_check** воспринят как баг/лишняя трата | Сред. / Низк. | Документировано как намеренное (soft pre-check агента vs авторитетный код против промоутимого образа). Будущая оптимизация — облегчить шаг A. |
| R-9 | **Самохостинг-bootstrap**: фича не действует, пока сама не в проде (старый прод-образ без лейбла) | Высок. (однократно) / Сред. | Bootstrap-чеклист (IR-7): первый реальный staging-прогон + ручной разрыв; B обратносовместим (без `EXPECTED_REVISION` — старое поведение), раскат поэтапный через флаг. |
| R-10 | **Деградация не-self репо** | Низк. / Высок. | Условность (`image_freshness_repos` пусто → только orchestrator); для прочих — `(True, "N/A")` + хук без `EXPECTED_REVISION` = прежний путь. AC-5. |

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,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

@@ -13,11 +13,25 @@
# When set, the prevalidated (staging) image is retagged onto
# TARGET_IMAGE instead of rebuilding — guarantees prod runs the
# exact artefact that passed staging (no `docker build`).
# EXPECTED_REVISION- expected git SHA of SOURCE_IMAGE (default: unset; ORCH-58)
# Strategy B fail-closed provenance guard: when set, the
# SOURCE_IMAGE's org.opencontainers.image.revision label MUST
# equal this value before the BUILD-ONCE retag, else exit 1
# (a stale image is never promoted). Unset -> no check (legacy).
# GIT_SHA - build-arg for --build-staging (default: unset; ORCH-58)
# BUILD_CONTEXT - docker build context dir (default: $REPO; --build-staging)
# STAGING_CONTAINER- container to docker-exec staging_check in (--build-staging;
# default: $TARGET_SERVICE → orchestrator-staging; ORCH-58)
# STAGING_CHECK_PATH- staging_check.py path inside that container (--build-staging;
# default: /repos/orchestrator/scripts/staging_check.py; ORCH-58)
# STAGING_CHECK_MODE- staging_check mode stub|full-real (--build-staging;
# default: stub — fast, no LLM spend; ORCH-58)
# LOG - log file path (default: /var/log/orchestrator/deploy-hook.log)
#
# Usage:
# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default)
# ./orchestrator-deploy-hook.sh --rollback # manual rollback
# ./orchestrator-deploy-hook.sh [--deploy] # normal deploy (default)
# ./orchestrator-deploy-hook.sh --rollback # manual rollback
# ./orchestrator-deploy-hook.sh --build-staging # ORCH-58: rebuild staging image (8501)
set -euo pipefail
@@ -32,6 +46,11 @@ PREV_IMAGE_FILE="${PREV_IMAGE_FILE:-$REPO/.deploy-prev-image-staging}"
# Build-once (ORCH-36): optional prevalidated source image to retag onto
# TARGET_IMAGE. Unset -> backward-compatible (no retag), exit-code contract intact.
SOURCE_IMAGE="${SOURCE_IMAGE:-}"
# Provenance guard (ORCH-58, Strategy B): expected git SHA of SOURCE_IMAGE. Unset
# -> backward-compatible (no provenance check), exit-code contract intact.
EXPECTED_REVISION="${EXPECTED_REVISION:-}"
# The OCI-standard label key the Dockerfile stamps with the build commit.
REVISION_LABEL="org.opencontainers.image.revision"
# ---- Log setup -------------------------------------------------------------
LOG_DIR=/var/log/orchestrator
@@ -129,6 +148,57 @@ if [[ "${1:-}" == "--rollback" ]]; then
fi
fi
# ============================================================================
# --build-staging mode (ORCH-58, Strategy A): rebuild the STAGING image from the
# VALIDATED commit, recreate 8501, and run the AUTHORITATIVE staging_check against
# the fresh image, so the artefact we validate is the exact one later BUILD-ONCE
# retagged to prod (INV-FRESH, AC-4). Builds/recreates STAGING ONLY (8501) — never
# prod (8500). Same exit-code contract (0 = healthy + staging_check PASS).
# GIT_SHA - commit stamped into the image revision label (build-arg).
# BUILD_CONTEXT - docker build context (host worktree of the validated commit).
# Steps: (1) docker build → (2) recreate 8501 → (3a) health-check →
# (3b) staging_check.py --mode stub against the fresh 8501 (ADR-001 step 3).
# ============================================================================
if [[ "${1:-}" == "--build-staging" ]]; then
BUILD_CONTEXT="${BUILD_CONTEXT:-$REPO}"
GIT_SHA="${GIT_SHA:-}"
log "BUILD-STAGING: rebuilding $TARGET_IMAGE from $BUILD_CONTEXT (GIT_SHA=$GIT_SHA, port=$TARGET_PORT)"
if ! docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT" >> "$LOG" 2>&1; then
log "BUILD-STAGING: docker build failed - aborting (exit 1)"
exit 1
fi
log "BUILD-STAGING: recreating $TARGET_SERVICE (profile=$COMPOSE_PROFILE) on the fresh image"
if [[ -n "$COMPOSE_PROFILE" ]]; then
docker compose --profile "$COMPOSE_PROFILE" up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
else
docker compose up -d --no-build "$TARGET_SERVICE" >> "$LOG" 2>&1
fi
log "BUILD-STAGING: running health-check on port $TARGET_PORT (10x6s)"
if ! health_check 10 6 "build-staging-health"; then
log "BUILD-STAGING: health FAILED after rebuild (exit 1)"
exit 1
fi
log "BUILD-STAGING: $TARGET_SERVICE healthy on fresh image"
# (3b) ORCH-58 (Strategy A, step 3 — ADR-001): authoritative e2e validation of
# the FRESH image. Run staging_check.py against the just-rebuilt 8501 INSIDE the
# staging container (ORCH-048 canonical: it reads its OWN staging registry env, so
# B6 is correct; the script lives at /repos/... via bind-mount, not in /app). This
# is the same artefact later BUILD-ONCE retagged to prod, so we validate exactly
# what we promote (AC-4). Any non-zero (FAIL or ORCH_STAGING safety-abort) -> exit 1
# -> freshness gate FAIL -> rollback to development. Same exit-code contract.
STAGING_CONTAINER="${STAGING_CONTAINER:-$TARGET_SERVICE}"
STAGING_CHECK_PATH="${STAGING_CHECK_PATH:-/repos/orchestrator/scripts/staging_check.py}"
STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}"
log "BUILD-STAGING: running staging_check (--mode $STAGING_CHECK_MODE) against fresh http://localhost:$TARGET_PORT inside $STAGING_CONTAINER"
if docker exec "$STAGING_CONTAINER" python3 "$STAGING_CHECK_PATH" \
--base-url "http://localhost:$TARGET_PORT" --mode "$STAGING_CHECK_MODE" >> "$LOG" 2>&1; then
log "BUILD-STAGING: staging_check PASS on fresh image (exit 0)"
exit 0
fi
log "BUILD-STAGING: staging_check FAILED on fresh image - artefact not promotable (exit 1)"
exit 1
fi
# ============================================================================
# NORMAL DEPLOY mode (--deploy or no argument)
# ============================================================================
@@ -156,6 +226,20 @@ git pull origin main >> "$LOG" 2>&1
# Backward compatible: skipped when SOURCE_IMAGE is unset.
if [[ -n "$SOURCE_IMAGE" ]]; then
if docker image inspect "$SOURCE_IMAGE" >/dev/null 2>&1; then
# ORCH-58 (Strategy B): fail-closed provenance guard BEFORE docker tag.
# When EXPECTED_REVISION is set, SOURCE_IMAGE's git-commit label MUST match,
# else exit 1 (FAILED -> БАГ-8 rollback); prod is NEVER touched. Empty label
# / inspect error / mismatch all fail-close. Unset EXPECTED_REVISION -> no
# check (backward-compatible for non-self repos / legacy calls).
if [[ -n "$EXPECTED_REVISION" ]]; then
IMG_REV=$(docker image inspect --format "{{ index .Config.Labels \"$REVISION_LABEL\" }}" "$SOURCE_IMAGE" 2>/dev/null || true)
if [[ "$IMG_REV" == "<no value>" ]]; then IMG_REV=""; fi
if [[ -z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION" ]]; then
log "PROVENANCE: SOURCE_IMAGE revision '$IMG_REV' != expected '$EXPECTED_REVISION' (fail-closed) - aborting (exit 1)"
exit 1
fi
log "PROVENANCE: SOURCE_IMAGE revision matches expected ($EXPECTED_REVISION) - retag allowed"
fi
log "BUILD-ONCE: retagging $SOURCE_IMAGE -> $TARGET_IMAGE (no rebuild)"
docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE" >> "$LOG" 2>&1
else

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().
@@ -582,6 +635,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

@@ -196,6 +196,106 @@ class Settings(BaseSettings):
deploy_prod_compose_profile: str = ""
deploy_prod_prev_image_file: str = ".deploy-prev-image-prod"
# ORCH-058: staging-image provenance before the BUILD-ONCE retag to prod.
# Closes the INV-FRESH gap (ADR-001): the BUILD-ONCE retag (ORCH-36) promotes
# the staging image to prod WITHOUT a rebuild, assuming the staging image is
# fresh — a guarantee the pipeline never had (a stale image could be silently
# promoted, LESSONS_ORCH-036 §4). Two complementary layers, self-hosting only:
# A (liveness): the QG sub-check check_staging_image_fresh rebuilds the
# staging image from the VALIDATED commit (worktree HEAD after merge-gate)
# and recreates 8501 on the deploy-staging -> deploy edge, so we validate
# and promote ONE artefact.
# B (safety): build_deploy_command passes EXPECTED_REVISION and the hook
# fail-closes (exit 1) if SOURCE_IMAGE's revision label != EXPECTED_REVISION
# before `docker tag`, making a silent stale promote structurally impossible.
#
# image_freshness_enabled -> SINGLE kill-switch for the WHOLE feature (A + B
# together; never "B without A" = a deadlock). False
# -> legacy ORCH-36 behaviour (BUILD-ONCE, no guard,
# no EXPECTED_REVISION). Env ORCH_IMAGE_FRESHNESS_ENABLED.
# image_freshness_repos -> CSV of repos where the feature is REAL; empty ->
# only the self-hosting repo (orchestrator). Mirrors
# self_deploy_repos / merge_gate_repos.
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
# retries, unresolved sha->branch). See docs/architecture/adr/adr-0007-reconciler.md.
# reconcile_enabled -> global kill-switch (self-hosting safety,
# staged rollout, env ORCH_RECONCILE_ENABLED).
# reconcile_interval_s -> background sweep period (seconds).
# reconcile_plane_enabled -> separate flag for the F-2 Plane-API poll so
# only the plane branch can be muted.
# reconcile_grace_default_s -> default "stuck" threshold on tasks.updated_at.
# reconcile_grace_overrides_json -> JSON object of per-stage thresholds, e.g.
# {"analysis": 1800, "development": 300}. Invalid
# 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"
# Telegram notifications
telegram_bot_token: str = ""
telegram_chat_id: str = ""

View File

@@ -1,6 +1,15 @@
import sqlite3
import threading
from .config import settings
# ORCH-053 (F-2 anti-dup): process-wide lock guarding the SELECT-exists -> INSERT
# task-creation claim. The prod topology is a single uvicorn process per DB
# (staging/prod isolated), with the webhook running in uvicorn's asyncio thread
# and the reconciler in its own thread of the SAME process -> a threading.Lock
# covers both sides of the create race without a schema migration. See
# docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md §4.
_CREATE_TASK_LOCK = threading.Lock()
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(settings.db_path)
@@ -145,6 +154,90 @@ def get_task_by_repo_branch(repo: str, branch: str) -> dict | None:
return None
def get_active_tasks_for_reconcile() -> list[dict]:
"""ORCH-053 (F-1): tasks eligible for the gate-side sweeper.
Returns every task whose stage is not terminal ('done'), each augmented with
``age_s`` = seconds since ``tasks.updated_at`` (computed in SQL against UTC
'now', matching how ``update_task_stage`` stamps ``updated_at``). The
reconciler applies the per-stage grace and active-job guard on top.
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT *, "
"CAST(strftime('%s','now') - strftime('%s', updated_at) AS INTEGER) AS age_s "
"FROM tasks WHERE stage != 'done'"
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def get_development_tasks_by_repo(repo: str) -> list[dict]:
"""ORCH-053 (F-3): tasks of a repo currently on the 'development' stage.
Used as the sha->branch DB fallback in handle_ci_status: a CI-status webhook
whose branch could not be resolved (no branches[], empty
``git branch -r --contains``) is matched to the unique development task of
the repo (ambiguity -> caller leaves it unresolved).
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT * FROM tasks WHERE repo = ? AND stage = 'development'", (repo,)
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def create_task_atomic(
plane_id: str,
work_item_id: str,
repo: str,
branch: str,
stage: str,
title: str,
) -> tuple[dict, bool]:
"""ORCH-053 (AC-4): atomically claim creation of a task for a plane_id.
Performs SELECT-exists -> INSERT under the process-wide ``_CREATE_TASK_LOCK``
so a race between the live Plane webhook and the F-2 reconciler (both seeing
"no task yet" for the same plane_id) cannot create two task rows / branches /
worktrees / starter analyst jobs.
Returns ``(row, created)``:
* ``created=True`` -> THIS caller inserted the row and owns the follow-up
work (branch / docs / analyst enqueue);
* ``created=False`` -> a task for this plane_id already existed (the other
racer won); ``row`` is the existing task and the caller must NOT duplicate
the follow-up work.
"""
with _CREATE_TASK_LOCK:
conn = get_db()
try:
existing = conn.execute(
"SELECT * FROM tasks WHERE plane_id = ? OR plane_issue_id = ?",
(plane_id, plane_id),
).fetchone()
if existing:
return dict(existing), False
cur = conn.execute(
"INSERT INTO tasks "
"(plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(plane_id, work_item_id, repo, branch, stage, plane_id, title),
)
conn.commit()
row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (cur.lastrowid,)
).fetchone()
return dict(row), True
finally:
conn.close()
def update_task_stage(task_id: int, stage: str):
"""Update task stage and timestamp."""
conn = get_db()

333
src/image_freshness.py Normal file
View File

@@ -0,0 +1,333 @@
"""Staging-image provenance for the BUILD-ONCE retag to prod (ORCH-058).
ORCH-36 made the ``deploy`` stage promote the staging image to prod by a plain
``docker tag`` (BUILD-ONCE, no rebuild), assuming "the staging image is fresh and
built from the validated code". That guarantee never existed: nothing in the
pipeline rebuilt the staging image from the validated commit, so a STALE image
could be silently promoted — the most dangerous bootstrap bug of LESSONS_ORCH-036
(§4): a green deploy that quietly rolled prod back to 2-day-old code.
This module provides the deterministic (no-LLM) primitives that enforce the
``INV-FRESH`` invariant (ADR-001), as **two complementary layers** wired only for
self-hosting:
* **A — liveness:** :func:`check_staging_image_fresh` is a QG sub-check on the
``deploy-staging -> deploy`` edge (composed by ``stage_engine`` AFTER the
merge-gate, BEFORE Phase A). It rebuilds ``orchestrator-orchestrator-staging``
from the VALIDATED commit (worktree HEAD after the merge-gate rebase), recreates
the 8501 container, and runs ``staging_check.py --mode stub`` against that fresh
8501 (ADR-001 step 3), so we validate exactly the ONE artefact later retagged to
prod (AC-4). FAIL -> rollback to ``development`` (mirrors the merge-gate).
* **B — safety:** :func:`expected_revision` feeds the validated SHA to
``self_deploy.build_deploy_command`` as ``EXPECTED_REVISION``; the host hook
fail-closes (``exit 1``) before ``docker tag`` if the SOURCE_IMAGE revision
label does not match. :func:`provenance_verdict` is the PURE verdict logic
that mirrors the hook's comparison (unit-tested in isolation).
Both layers share ONE anchor — :func:`validated_revision` — so the build stamp (A)
and the expected revision (B) can never diverge.
This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily
``qg.checks.is_self_hosting_repo``; it never imports ``stage_engine`` /
``self_deploy``. Every public helper honours a strict **never-raise** contract and
is **fail-closed** on any doubt (missing label, empty SHA, docker/ssh/inspect
error) -> treated as a mismatch, never promoted "on faith".
"""
import logging
import os
import shlex
import subprocess
from .config import settings
logger = logging.getLogger("orchestrator.image_freshness")
# The OCI-standard label key carrying the build commit (Dockerfile stamps it).
REVISION_LABEL = "org.opencontainers.image.revision"
# Bounded timeouts so a hung git/docker/ssh never wedges the monitor-thread.
_GIT_TIMEOUT = 30
_INSPECT_TIMEOUT = 30
# The remote rebuild (docker build + compose recreate + health + staging_check) is
# the slow path; keep it generous but bounded (mirrors the merge-gate re-test order).
_REBUILD_TIMEOUT = 1200
# Explicit STAGING target for the --build-staging rebuild (Strategy A). These mirror
# the hook's staging-safe defaults but are passed EXPLICITLY so a future change to the
# hook defaults can never silently retarget the self-rebuild at prod (8500) — the whole
# path builds/recreates STAGING ONLY (AC-9, review P2). Never the prod 8500 target.
_STAGING_SERVICE = "orchestrator-staging"
_STAGING_PORT = 8501
_STAGING_COMPOSE_PROFILE = "staging"
# ---------------------------------------------------------------------------
# Conditionality (mirrors self_deploy_applies / _merge_gate_applies)
# ---------------------------------------------------------------------------
def image_freshness_applies(repo: str) -> bool:
"""Whether the staging-image provenance feature (A + B) is REAL for this repo.
Mirrors the ORCH-35 / ORCH-43 / ORCH-36 conditional rollout:
* ``image_freshness_enabled=False`` -> always False (single kill-switch for
the WHOLE feature; legacy ORCH-36 BUILD-ONCE behaviour for everyone).
* ``image_freshness_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.image_freshness_enabled:
return False
raw = (settings.image_freshness_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 (avoids importing qg at module load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("image_freshness_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# The validated-commit anchor (single source for both A and B)
# ---------------------------------------------------------------------------
def validated_revision(repo: str, branch: str) -> str:
"""Return the SHA of the VALIDATED commit = ``git rev-parse HEAD`` in the task
worktree AFTER the merge-gate (post auto-rebase + push --force-with-lease).
This is exactly the tree the merge-gate re-tested green and that merges into
``main``. It is the SINGLE anchor that feeds both the staging rebuild stamp (A)
and the expected revision passed to the hook (B), so the two layers cannot
disagree about "what commit prod must run".
Fail-closed / never-raise (AC-3 / AC-8): a missing worktree or any git/OS error
returns ``""`` (an empty SHA, which downstream treats as a provenance mismatch),
never a propagated exception.
"""
from .git_worktree import get_worktree_path
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("validated_revision: worktree error for %s/%s: %s", repo, branch, e)
return ""
if not os.path.isdir(wt):
logger.warning("validated_revision: no worktree at %s for %s/%s", wt, repo, branch)
return ""
try:
r = subprocess.run(
["git", "-C", wt, "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("validated_revision: git error for %s/%s: %s", repo, branch, e)
return ""
if r.returncode != 0:
logger.warning(
"validated_revision: rev-parse rc=%s for %s/%s", r.returncode, repo, branch
)
return ""
return (r.stdout or "").strip()
def expected_revision(repo: str, branch: str) -> str:
"""The revision the hook must require (Strategy B), or ``""`` when the feature
is inactive for this repo.
Returns :func:`validated_revision` ONLY when :func:`image_freshness_applies`
(so non-self / disabled callers get ``""`` -> the hook keeps its backward-
compatible "no provenance check" behaviour, no EXPECTED_REVISION env). The
config invariant (ADR-001) is that B is active iff A is active — both gated by
the SAME flag — so there is never a "B without A" deadlock. Never raises.
"""
try:
if not image_freshness_applies(repo):
return ""
return validated_revision(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("expected_revision error for %s/%s: %s", repo, branch, e)
return ""
# ---------------------------------------------------------------------------
# Pure provenance verdict (mirrors the hook's bash comparison — Strategy B)
# ---------------------------------------------------------------------------
def provenance_verdict(expected_sha: str, image_sha: str) -> tuple[bool, str]:
"""Pure, deterministic provenance verdict (no I/O) — the Python mirror of the
hook's fail-closed comparison (Strategy B), unit-testable in isolation.
Contract (AC-1 / AC-2 / AC-3, fail-closed):
* both non-empty AND equal -> ``(True, "provenance match: <sha12>")``.
* expected empty / image empty -> ``(False, "...")`` — fail-closed: a
missing expected SHA or an unlabelled image is NEVER treated as fresh.
* both non-empty but different -> ``(False, "provenance mismatch ...")``.
"""
exp = (expected_sha or "").strip()
img = (image_sha or "").strip()
if not exp:
return False, "provenance fail-closed: empty expected revision"
if not img:
return False, "provenance fail-closed: image has no revision label"
if exp == img:
return True, f"provenance match: {exp[:12]}"
return False, f"provenance mismatch: image {img[:12]} != expected {exp[:12]}"
def image_revision(image: str, ssh_target: str | None = None) -> str:
"""Read an image's ``org.opencontainers.image.revision`` label via
``docker image inspect``. Returns ``""`` on any error or when the label is
absent (fail-closed -> downstream treats it as a mismatch).
``docker`` lives on the HOST (the container ships only ``openssh-client git``),
so when ``ssh_target`` is given the inspect runs over ssh; otherwise it runs
locally (covers host-side callers and tests). Never raises (AC-8).
"""
fmt = '{{ index .Config.Labels "%s" }}' % REVISION_LABEL
local_cmd = ["docker", "image", "inspect", "--format", fmt, image]
if ssh_target:
remote = "docker image inspect --format " + shlex.quote(fmt) + " " + shlex.quote(image)
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", ssh_target, remote]
else:
cmd = local_cmd
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_INSPECT_TIMEOUT)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("image_revision: inspect error for %s: %s", image, e)
return ""
if r.returncode != 0:
logger.warning("image_revision: inspect rc=%s for %s", r.returncode, image)
return ""
out = (r.stdout or "").strip()
# `docker inspect` prints "<no value>" for a missing label key.
if out in ("", "<no value>"):
return ""
return out
# ---------------------------------------------------------------------------
# Staging rebuild from the validated commit (Strategy A) — host-side via the hook
# ---------------------------------------------------------------------------
def _ssh_target() -> str | None:
"""ssh ``user@host`` for the host rebuild, or None when no host is configured
(tests / non-self contexts that mock this away)."""
host = (settings.deploy_ssh_host or "").strip()
if not host:
return None
user = (settings.deploy_ssh_user or "").strip()
return f"{user}@{host}" if user else host
def _host_worktree_path(repo: str, branch: str) -> str:
"""The task worktree path AS SEEN FROM THE HOST (docker build context).
The container path uses ``settings.worktrees_dir`` (under ``repos_dir``); the
host sees the same files under ``host_repos_dir``. Derive the host path by
swapping the mount prefix (mirrors ``self_deploy.host_state_dir``).
"""
from .git_worktree import get_worktree_path
container_wt = get_worktree_path(repo, branch)
repos_dir = settings.repos_dir.rstrip("/")
host_repos_dir = settings.host_repos_dir.rstrip("/")
if container_wt.startswith(repos_dir):
return host_repos_dir + container_wt[len(repos_dir):]
return container_wt
def rebuild_staging_image(repo: str, branch: str, sha: str) -> tuple[bool, str]:
"""Rebuild the staging image from the VALIDATED commit and recreate 8501
(Strategy A) by invoking the host hook in ``--build-staging`` mode over ssh.
The hook (``orchestrator-deploy-hook.sh --build-staging``) runs, on the host:
``docker build --build-arg GIT_SHA=<sha> -t <staging-image> <host-worktree>``
-> ``docker compose --profile staging up -d --no-build orchestrator-staging``
-> health-check 8501
-> ``staging_check.py --mode stub`` against the FRESH 8501 (ADR-001 step 3,
AC-4: validate exactly the artefact later retagged to prod).
Same exit-code contract (0 = ok). This trades prod for staging ONLY (8501),
NEVER prod (8500) (AC-9): all build/recreate/validate targets are the staging
service — passed EXPLICITLY below, not left to hook defaults (review P2).
Synchronous ssh is fine here (unlike Phase B): recreating staging does not kill
the prod worker running this code. Bounded by ``_REBUILD_TIMEOUT``.
Returns ``(True, msg)`` on a healthy rebuild, else ``(False, reason)``.
Never raises (AC-8).
"""
target = _ssh_target()
if not target:
return False, "no ssh host configured for staging rebuild"
host_ctx = _host_worktree_path(repo, branch)
# Pass the STAGING target explicitly (service/port/profile/container), so the
# rebuild + recreate + staging_check can never drift onto the prod 8500 service
# even if the hook's defaults change (AC-9, review P2). STAGING_CONTAINER is the
# container staging_check is docker-exec'd inside (step 3b).
env_assignments = (
f"GIT_SHA={shlex.quote(sha)} "
f"BUILD_CONTEXT={shlex.quote(host_ctx)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_source_image)} "
f"TARGET_SERVICE={shlex.quote(_STAGING_SERVICE)} "
f"TARGET_PORT={shlex.quote(str(_STAGING_PORT))} "
f"COMPOSE_PROFILE={shlex.quote(_STAGING_COMPOSE_PROFILE)} "
f"STAGING_CONTAINER={shlex.quote(_STAGING_SERVICE)}"
)
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "
f"bash {shlex.quote(settings.deploy_hook_script)} --build-staging"
)
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", target, inner]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_REBUILD_TIMEOUT)
except subprocess.TimeoutExpired:
return False, f"staging rebuild timeout after {_REBUILD_TIMEOUT}s"
except (subprocess.SubprocessError, OSError) as e:
return False, f"staging rebuild ssh error: {e}"
if r.returncode != 0:
detail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:]
return False, f"staging rebuild failed (rc={r.returncode}): {detail}"
logger.info("rebuild_staging_image: %s/%s rebuilt from %s and healthy", repo, branch, sha[:12])
return True, f"staging rebuilt from {sha[:12]} and healthy"
# ---------------------------------------------------------------------------
# QG sub-check: check_staging_image_fresh (Strategy A liveness, AC-4/AC-6)
# ---------------------------------------------------------------------------
def check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-058 freshness sub-gate on the ``deploy-staging -> deploy`` edge.
Deterministic, no LLM. Mirrors ``check_branch_mergeable`` (ORCH-043):
1. Conditionality: ``image_freshness_enabled=False`` -> ``(True, "...disabled")``;
a repo the feature is not real for -> ``(True, "image-freshness N/A for <repo>")``.
2. Anchor: ``sha = validated_revision(repo, branch)``. Empty -> fail-closed
``(False, ...)`` (AC-3): we never rebuild/promote without a known commit.
3. Rebuild the staging image from that commit, recreate 8501, and run
``staging_check.py --mode stub`` against the fresh 8501 (host hook). PASS ->
``(True, ...)``: the artefact we just validated (build + e2e) is the exact
one that will be retagged to prod (AC-4, loop closed). FAIL -> ``(False, ...)``
-> the engine rolls back to ``development`` (AC-2).
Never-raise (AC-8): any internal error -> ``(False, "<reason>")``; an exception
never escapes into ``advance_stage``. Returns ``(True, "N/A")`` for non-self
repos so the deploy edge is unchanged for them (AC-5).
"""
try:
if not settings.image_freshness_enabled:
return True, "image-freshness disabled"
if not image_freshness_applies(repo):
return True, f"image-freshness N/A for {repo}"
sha = validated_revision(repo, branch)
if not sha:
# Fail-closed: without the validated commit we cannot prove freshness.
return False, "cannot resolve validated revision (fail-closed)"
ok, reason = rebuild_staging_image(repo, branch, sha)
if not ok:
return False, f"staging rebuild failed: {reason}"
return True, f"staging image fresh ({sha[:12]})"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.error("check_staging_image_fresh error for %s/%s: %s", repo, branch, e)
return False, f"image-freshness error: {e}"

View File

@@ -80,11 +80,19 @@ async def lifespan(app: FastAPI):
from .queue_worker import worker
worker.start()
# ORCH-053: start the stuck-task reconciler AFTER the worker so its active-job
# guard sees a fully-initialised queue. Kill-switch: ORCH_RECONCILE_ENABLED.
from .reconciler import reconciler
reconciler.start()
try:
yield
finally:
# Graceful shutdown of 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 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.
reconciler.stop()
worker.stop()
@@ -114,10 +122,14 @@ async def queue():
"""ORCH-1: job-queue observability — status counts + recent jobs."""
from .db import job_status_counts, recent_jobs
from .queue_worker import worker
from .reconciler import reconciler
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(),
"post_deploy": post_deploy.status(),
"recent": recent_jobs(10),
}

View File

@@ -278,6 +278,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
@@ -356,6 +383,62 @@ def fetch_issue_fields(issue_id: str, project_id: str) -> tuple[str, str]:
return "", ""
def list_issues_by_state(project_id: str, state_uuids: list[str]) -> list[dict]:
"""ORCH-053 (F-2): list a project's issues whose state is in ``state_uuids``.
GETs ``/workspaces/{ws}/projects/{pid}/issues/`` and walks ALL pages
(Plane's cursor pagination: ``results`` + ``next_cursor`` /
``next_page_results``), keeping only issues whose state uuid is one of the
requested ones. The filter is applied client-side on ``issue.state`` (a dict
``{id,...}`` or a bare uuid string) so it works regardless of whether Plane's
query-param state filter is honoured.
Never raises: on any network / API / shape error it logs a warning and
returns ``[]`` so a Plane outage degrades the F-2 tick softly instead of
crashing it.
"""
if not project_id or not state_uuids:
return []
wanted = set(state_uuids)
out: list[dict] = []
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/"
try:
cursor = None
pages = 0
while True:
params: dict = {"per_page": 100}
if cursor:
params["cursor"] = cursor
resp = httpx.get(url, headers=PLANE_HEADERS, params=params, timeout=10)
resp.raise_for_status()
body = resp.json()
if isinstance(body, dict):
items = body.get("results", [])
else:
items = body if isinstance(body, list) else []
for issue in items:
state = issue.get("state")
sid = state.get("id") if isinstance(state, dict) else state
if sid in wanted:
out.append(issue)
# Pagination: continue only while Plane reports more pages.
pages += 1
if not isinstance(body, dict):
break
has_more = bool(body.get("next_page_results"))
next_cursor = body.get("next_cursor")
if not has_more or not next_cursor or pages >= 100:
break
cursor = next_cursor
return out
except Exception as e:
logger.warning(
f"list_issues_by_state: API failed for project {project_id[:8]}..., "
f"returning []. Error: {e}"
)
return []
def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
"""Find Plane issue UUID by work_item_id (e.g. 'ET-002')."""
project_id = _resolve_project_id(work_item_id, project_id)

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

@@ -702,6 +702,20 @@ def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[b
return False, f"merge-gate error: {e}"
def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
"""ORCH-058 freshness sub-gate (Strategy A) on the deploy-staging -> deploy edge.
Thin registry wrapper that delegates to ``image_freshness.check_staging_image_fresh``
(rebuild the staging image from the validated commit + recreate 8501). The real
logic lives in ``src/image_freshness.py`` (leaf module, never-raise, fail-closed);
importing it lazily here avoids an import cycle (image_freshness imports
is_self_hosting_repo from this module). For non-self repos it returns
``(True, "N/A")`` so the deploy edge is unchanged for them (AC-5).
"""
from ..image_freshness import check_staging_image_fresh
return check_staging_image_fresh(repo, work_item_id, branch)
# Registry for dynamic lookup by name
QG_CHECKS = {
"check_analysis_approved": check_analysis_approved,
@@ -715,4 +729,5 @@ QG_CHECKS = {
"check_deploy_status": check_deploy_status,
"check_staging_status": check_staging_status,
"check_branch_mergeable": check_branch_mergeable,
"check_staging_image_fresh": _check_staging_image_fresh,
}

387
src/reconciler.py Normal file
View File

@@ -0,0 +1,387 @@
"""ORCH-053: stuck-task reconciler (sweeper for lost webhooks).
The pipeline advances ONLY on incoming webhooks (Plane status / Gitea CI/PR). A
dropped event (502 on a rebuilding instance, no Plane/Gitea retries, an
unresolved ``sha->branch``) leaves the source of truth (the gate / the Plane
status) changed while the task stays put — a silently stuck task (incident
ORCH-044). None of the existing resilience layers (``requeue_running_jobs``,
orphan-recovery, events de-dup, ``ci_poll``) reconcile this
"source-of-truth != task-stage" drift; they all work at the jobs/agent_runs
level, not the stage transition.
This module is a background daemon thread (modelled on ``queue_worker``) that
periodically replays the missed transition through the SAME standard gates /
handlers a webhook would use:
* **F-1 gate-side** (``reconcile_gate_once``): for each task with
``stage != 'done'``, no active job and ``age(updated_at) >=
grace_for_stage(stage)``, do a read-only pre-evaluation of the stage's
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). **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 /
Rejected through ``webhooks.plane.handle_status_start`` /
``handle_verdict`` (no logic duplicated).
Invariants: source of truth is the gate / Plane (not the event); advance only
via ``advance_stage``; idempotency (active-job guard + atomic create-claim +
grace + ``max_concurrency=1``); never-raise per unit of work; silence when in
sync; restart-safe; kill-switch ``ORCH_RECONCILE_ENABLED``
(+ ``ORCH_RECONCILE_PLANE_ENABLED`` mutes only F-2). The DB schema and the
registries (``STAGE_TRANSITIONS`` / ``QG_CHECKS``) are unchanged.
See docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md and the
cross-cutting docs/architecture/adr/adr-0007-reconciler.md.
"""
import asyncio
import json
import logging
import threading
from datetime import datetime, timezone
from .config import settings
from .db import (
get_active_tasks_for_reconcile,
get_task_by_plane_id,
has_active_job_for_task,
)
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 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
logger = logging.getLogger("orchestrator.reconciler")
def _parse_grace_overrides(raw: str) -> dict[str, int]:
"""Parse ``reconcile_grace_overrides_json`` into {stage: seconds}.
Invalid / non-object JSON -> {} (caller falls back to the default grace),
mirroring the never-raise contract of ``agent_timeout_overrides_json``.
"""
if not raw or not raw.strip():
return {}
try:
data = json.loads(raw)
except (ValueError, TypeError) as e:
logger.warning(f"reconcile_grace_overrides_json is not valid JSON, ignoring: {e}")
return {}
if not isinstance(data, dict):
logger.warning("reconcile_grace_overrides_json must be a JSON object, ignoring")
return {}
out: dict[str, int] = {}
for k, v in data.items():
try:
out[str(k)] = int(v)
except (ValueError, TypeError):
logger.warning(f"reconcile_grace_overrides_json[{k}] is not an int, ignoring")
return out
def grace_for_stage(stage: str) -> int:
"""Per-stage "stuck" threshold (seconds): override from JSON, else default."""
overrides = _parse_grace_overrides(settings.reconcile_grace_overrides_json)
return overrides.get(stage, settings.reconcile_grace_default_s)
def _age_seconds_iso(ts: str) -> float | None:
"""Age in seconds of a Plane ISO-8601 timestamp (e.g. issue.updated_at).
Returns None when the value is missing / unparseable (caller decides the
fallback). Handles a trailing 'Z' and treats naive timestamps as UTC.
"""
if not ts:
return None
try:
text = ts.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - dt).total_seconds()
except Exception:
return None
class Reconciler:
"""Background daemon that reconciles webhook-induced stage drift.
Modelled on ``QueueWorker``: a plain ``threading.Thread(daemon=True)`` +
``threading.Event`` for a clean stop. No correctness-critical state is held
in memory — every tick re-reads the DB / Plane; the observability counters
(``last_run_ts`` / ``unblocked_total`` / ``last_unblocked``) are best-effort
and may reset on restart (AC-11 allows this).
"""
def __init__(self, interval_s: float | None = None):
self.interval_s = (
interval_s if interval_s is not None else settings.reconcile_interval_s
)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# Best-effort observability (F-4).
self.last_run_ts: float | None = None
self.unblocked_total: int = 0
self.last_unblocked: str | None = None
# -- F-1: gate-side ----------------------------------------------------
def reconcile_gate_once(self) -> None:
"""One F-1 pass over all non-terminal tasks (per-task never-raise)."""
if not settings.reconcile_enabled:
return
for task in get_active_tasks_for_reconcile():
try:
self._reconcile_gate_task(task)
except Exception as e: # noqa: BLE001 - isolate one task's failure
logger.error(
f"reconciler F-1: task {task.get('id')} "
f"(stage={task.get('stage')}) failed: {e}"
)
def _reconcile_gate_task(self, task: dict) -> None:
task_id = task["id"]
stage = task["stage"]
# AC-16: analysis is a human gate -> owned by F-2, never F-1.
if stage == "analysis":
return
# created / done have no gate to evaluate.
if get_qg_for_stage(stage) is None:
return
# AC-3: a queued/running job means the task is legitimately in flight (or
# a live webhook just enqueued one) -> do not touch it.
if has_active_job_for_task(task_id):
return
# AC-5: respect the per-stage grace ("stuck", not just busy).
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,
task["repo"],
task.get("work_item_id") or "",
task.get("branch") or "",
)
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:
"""ORCH-060 Guard 2: is this issue in an explicit human Plane gate?
Variant A (no schema migration): resolve the task's Plane project, fetch
the issue's current state uuid and compare against the project's
``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so
the live Plane state is the source of truth.
**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
return cur in {states.get("blocked"), states.get("needs_input")}
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."""
if not settings.reconcile_enabled or not settings.reconcile_plane_enabled:
return
for proj in projects.PROJECTS:
try:
self._reconcile_plane_project(proj)
except Exception as e: # noqa: BLE001 - isolate one project's failure
logger.error(f"reconciler F-2: project {proj.repo} failed: {e}")
def _reconcile_plane_project(self, proj) -> None:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
states = get_project_states(pid)
in_progress = states["in_progress"]
approved = states["approved"]
rejected = states["rejected"]
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
f"reconciler F-2: issue {issue.get('id')} failed: {e}"
)
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
return
state = issue.get("state")
new_state = state.get("id") if isinstance(state, dict) else state
# Grace ("lost, not merely delayed"): use the issue's own updated_at age.
# A missing/unparseable timestamp is treated as old enough (the active-job
# guard + atomic create-claim still prevent doubling).
age = _age_seconds_iso(issue.get("updated_at") or "")
if age is not None and age < settings.reconcile_grace_default_s:
return
task = get_task_by_plane_id(issue_id)
# AC-3/AC-4: a live webhook is in flight for this task -> skip.
if task is not None and has_active_job_for_task(task["id"]):
return
# issue_data in the shape the plane handlers expect; missing name /
# description are pulled by the handlers themselves (fetch_issue_fields).
issue_data = {
"id": issue_id,
"state": {"id": new_state},
"project": project_id,
"name": issue.get("name", ""),
"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).
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(issue_id, "analysis")
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)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
elif new_state == rejected and task is not None:
# Rejected but never rolled back -> replay the verdict.
self._dispatch(handle_verdict, issue_data, project_id, approved=False)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
# else: everything is in sync -> silence (AC-10).
@staticmethod
def _dispatch(coro_fn, *args, **kwargs) -> None:
"""Run an async plane handler from this sync thread.
``asyncio.run`` spins a fresh event loop per call, which is required
because ``handle_verdict -> _try_advance_stage`` uses
``asyncio.to_thread`` (needs a running loop). The handlers are
REUSED verbatim — no pipeline logic is duplicated here.
"""
asyncio.run(coro_fn(*args, **kwargs))
# -- observability (F-4) ----------------------------------------------
def _note_unblock(self, work_item_id: str, stage: str) -> None:
"""Record + announce that a stuck task was unblocked (AC-12).
Fires only on an actual state change (an advance / replayed transition),
never per idle tick, so it does not conflict with AC-9 / AC-10.
"""
self.unblocked_total += 1
self.last_unblocked = work_item_id
logger.info(
f"reconciler: {work_item_id} {stage} разблокирована (потерян webhook)"
)
if settings.reconcile_notify_unblock:
try:
send_telegram(
f"\U0001f527 reconciler: {work_item_id} {stage} "
f"разблокирована (потерян webhook)"
)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"reconciler: unblock telegram failed: {e}")
# -- loop / lifecycle --------------------------------------------------
def _tick(self) -> None:
if settings.reconcile_enabled:
self.reconcile_gate_once() # F-1
if settings.reconcile_plane_enabled:
self.reconcile_plane_once() # F-2
self.last_run_ts = datetime.now(timezone.utc).timestamp()
def _run(self) -> None:
logger.info(
f"Reconciler started (interval={self.interval_s}s, "
f"enabled={settings.reconcile_enabled}, "
f"plane_enabled={settings.reconcile_plane_enabled})"
)
while not self._stop.is_set():
try:
self._tick()
except Exception as e: # noqa: BLE001 - outer never-raise
logger.error(f"Reconciler loop error: {e}")
self._stop.wait(self.interval_s)
logger.info("Reconciler 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="reconciler", 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:
"""Reconcile snapshot for /queue observability."""
return {
"enabled": settings.reconcile_enabled,
"plane_enabled": settings.reconcile_plane_enabled,
"interval": self.interval_s,
"last_run_ts": self.last_run_ts,
"unblocked_total": self.unblocked_total,
"last_unblocked": self.last_unblocked,
}
# Module-level singleton used by the FastAPI lifespan.
reconciler = Reconciler()

View File

@@ -230,7 +230,17 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
Build-once (BR-6): ``SOURCE_IMAGE=<staging-image>`` makes the hook retag the
staging-validated image to the prod tag instead of rebuilding (no ``docker
build``). The exit-code contract of the hook is untouched.
Provenance guard (ORCH-058, Strategy B): when the image-freshness feature is
active for this repo, the VALIDATED commit SHA is passed as
``EXPECTED_REVISION=<sha>`` so the hook fail-closes (``exit 1``) before
``docker tag`` if SOURCE_IMAGE's revision label does not match — a stale image
can never be silently promoted. When inactive (non-self / kill-switch off)
``expected_revision`` returns ``""`` and the env is omitted, keeping the hook's
backward-compatible "no provenance check" behaviour (AC-5 / AC-7).
"""
from . import image_freshness
host_dir = host_state_dir(repo, work_item_id)
result_sentinel = os.path.join(host_dir, RESULT)
hook_log = os.path.join(host_dir, "hook.log")
@@ -243,6 +253,9 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} "
f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}"
)
expected_rev = image_freshness.expected_revision(repo, branch)
if expected_rev:
env_assignments += f" EXPECTED_REVISION={shlex.quote(expected_rev)}"
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "

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,
@@ -142,8 +143,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 +160,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,
@@ -271,6 +282,17 @@ def advance_stage(
):
return result
# --- ORCH-058 freshness sub-gate (deploy-staging -> deploy edge) ---
# AFTER the merge-gate finalised the validated HEAD and BEFORE Phase A.
# Rebuilds the staging image from that validated commit + recreates 8501
# so the artefact we validate is the exact one promoted to prod (AC-4).
# FAIL -> rollback to development (mirrors the merge-gate). Like the
# merge-gate it owns the outcome on intervention.
if _handle_image_freshness(
task_id, current_stage, repo, work_item_id, branch, agent, result
):
return result
# --- ORCH-036 Phase A: request approve before the prod deploy ---------
# On the deploy-staging -> deploy edge, AFTER a green check_staging_status
# and the merge-gate, the self-hosting repo does NOT auto-launch a prod
@@ -331,6 +353,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:
@@ -353,6 +386,75 @@ def advance_stage(
return result
def advance_if_gate_passed(
task_id: int,
current_stage: str,
repo: str,
work_item_id: str,
branch: str,
) -> AdvanceResult | None:
"""ORCH-053 (F-1): reconcile a stuck stage by advancing it ONLY if its
quality gate is already green — without spamming failure notifications.
This is the thin wrapper the reconciler uses so that:
* The source of truth stays the GATE, and the advance path stays the
UNCHANGED unified ``advance_stage(..., finished_agent=None)`` (the same
path the Plane Approved-webhook uses). The reconciler never duplicates
``update_task_stage`` / ``enqueue_job`` (AC-2).
* On a stable-RED gate the sweeper is structurally silent: we do a cheap
read-only pre-evaluation of the gate and, if it fails, return ``None``
WITHOUT ever calling ``advance_stage`` — so the QG-failure notification
branch inside ``advance_stage`` (``agent is None`` ->
``notify_qg_failure`` + ``plane_notify_qg``) cannot fire on any tick
(AC-9). Spam is impossible by construction.
``analysis`` is intentionally NOT reconciled here: its gate
(``check_analysis_approved``) is a HUMAN gate; with ``finished_agent=None``
``advance_stage`` would treat it as approved-via-status and could advance an
unapproved BRD. The analysis advance is owned by the Plane-side reconciler
(F-2), which checks the real Plane status (AC-16).
Returns the ``AdvanceResult`` from ``advance_stage`` when the gate passed,
or ``None`` when the stage is not eligible / the gate is red / on any error
(never raises — the caller isolates per-task failures).
"""
try:
# AC-16: F-1 never reconciles the human analysis gate.
if current_stage == "analysis":
return None
qg_name = get_qg_for_stage(current_stage)
if not qg_name:
# created / done -> no gate to evaluate.
return None
# Read-only pre-evaluation with the SAME dispatcher the webhook path uses.
passed, reason = _run_qg(qg_name, repo, work_item_id, branch)
if not passed:
# Stable-red -> stay silent (no advance_stage call -> no QG-failure
# notification on this or any later tick).
logger.debug(
f"reconciler: task {task_id} gate '{qg_name}' still red "
f"({reason}); leaving on '{current_stage}'"
)
return None
# Gate is green: advance via the unchanged unified path. It re-runs the
# (idempotent, read-only) gate, advances the stage, sends the STANDARD
# advance notifications and enqueues the next agent.
return advance_stage(
task_id, current_stage, repo, work_item_id, branch, finished_agent=None
)
except Exception as e: # noqa: BLE001 - never-raise per ORCH-053 NFR
logger.error(
f"advance_if_gate_passed failed for task_id={task_id} "
f"stage={current_stage}: {e}"
)
return None
def _build_analyst_ready_comment(
repo: str, work_item_id: str, branch: str, task_id: int | None = None
) -> str:
@@ -809,6 +911,83 @@ def _handle_merge_gate_rollback(
)
# ---------------------------------------------------------------------------
# ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge
# ---------------------------------------------------------------------------
def _handle_image_freshness(
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
) -> bool:
"""Run check_staging_image_fresh on the deploy-staging -> deploy edge (ORCH-058).
Runs AFTER the merge-gate (validated HEAD finalised) and BEFORE Phase A. The
sub-check rebuilds the staging image from the validated commit + recreates 8501;
a green result means the artefact we validate is the exact one that will be
BUILD-ONCE retagged to prod (AC-4).
Returns True if the gate INTERVENED (the caller must return without advancing):
* FAIL (stale / rebuild error / fail-closed) -> ROLLBACK to development
(+ developer retry, capped by MAX_DEVELOPER_RETRIES) and RELEASE the merge
lease (the merge-gate held it on its PASS). Mirrors the merge-gate rollback.
Returns False when the gate PASSED (fresh, or N/A for a non-self repo) so
advance_stage proceeds to Phase A. On a PASS the merge lease stays HELD until
the actual merge (released on PR-merged webhook / deploy->done / rollback).
"""
passed, reason = _run_qg("check_staging_image_fresh", repo, work_item_id, branch)
if passed:
logger.info(f"Task {task_id}: image-freshness passed ({reason})")
return False
result.qg_name = "check_staging_image_fresh"
result.qg_passed = False
result.qg_reason = reason
update_task_stage(task_id, "development")
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
set_issue_in_progress(work_item_id)
# The merge-gate held the lease on its PASS; freshness failed before the merge,
# so release it (holder-aware no-op if a different task already owns it).
try:
merge_gate.release_merge_lease(repo, branch)
except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on image-freshness fail failed: {e}")
notify_qg_failure(task_id, current_stage, "check_staging_image_fresh", reason)
plane_add_comment(
work_item_id,
f"❌ Staging-образ не свеж ({reason}). Откат на development. "
f"Developer нужен для фикса.",
author="deployer",
)
retry_count = _developer_retry_count(task_id)
if retry_count < MAX_DEVELOPER_RETRIES:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: development\nNote: Staging image freshness failed "
f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). "
f"Причина: {reason}."
)
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
result.enqueued_agent = "developer"
result.enqueued_job_id = new_job
logger.info(
f"Task {task_id}: image-freshness FAILED, enqueued developer (job_id={new_job})"
)
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Staging image freshness still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
result.alerted = True
logger.error(
f"Task {task_id}: image-freshness FAILED, rolled back deploy-staging -> "
f"development ({reason})"
)
return True
# ---------------------------------------------------------------------------
# ORCH-036: executable self-deploy (Phase A/B/C)
# ---------------------------------------------------------------------------
@@ -1009,3 +1188,139 @@ 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)
_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).",
)
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

@@ -144,6 +144,36 @@ async def handle_push(payload: dict):
logger.info(f"Task {task_id}: source push detected on '{branch}', waiting for CI")
def _resolve_branch_via_db(repo_name: str) -> str:
"""ORCH-053 (F-3): resolve a CI-status SHA to a branch via the tasks DB.
Returns the branch of the SINGLE development-stage task for ``repo_name``.
If there are zero or several such tasks the match is ambiguous -> return ""
(the caller leaves the branch unresolved; never a false match). Logged at
INFO for visibility. Never raises.
"""
try:
from ..db import get_development_tasks_by_repo
devs = get_development_tasks_by_repo(repo_name)
except Exception as e: # noqa: BLE001 - defensive, never break the webhook
logger.info(f"CI status: sha->branch DB fallback errored for {repo_name}: {e}")
return ""
if len(devs) == 1:
branch = devs[0].get("branch") or ""
if branch:
logger.info(
f"CI status: sha->branch resolved via DB fallback to '{branch}' "
f"(unique development task in {repo_name})"
)
return branch
if len(devs) > 1:
logger.info(
f"CI status: sha->branch DB fallback ambiguous "
f"({len(devs)} development tasks in {repo_name}), leaving unresolved"
)
return ""
async def handle_ci_status(payload: dict):
"""
CI status update:
@@ -178,7 +208,15 @@ async def handle_ci_status(payload: dict):
except Exception:
pass
if not branch:
logger.debug(f"CI status event: could not determine branch for sha={sha}")
# ORCH-053 (F-3): DB fallback — when the SHA cannot be resolved to a
# branch (lost on a 502 rebuild, etc.), match it to the UNIQUE
# development-stage task of this repo. Ambiguity (more than one) is
# left unresolved to avoid a false match; the F-1 sweeper still picks
# such a task up later (defense-in-depth, not the critical path).
branch = _resolve_branch_via_db(repo_name)
if not branch:
# logger.info (was debug) so a lost CI event is VISIBLE in the logs.
logger.info(f"CI status event: could not determine branch for sha={sha}")
return
repo_name = payload.get("repository", {}).get("name", settings.default_repo)

View File

@@ -17,6 +17,7 @@ from ..db import (
update_task_stage,
enqueue_job,
insert_event_dedup,
create_task_atomic,
)
from ._dedup import plane_delivery_id
from ..stages import get_next_stage, get_agent_for_stage, get_qg_for_stage, get_previous_stage
@@ -496,15 +497,21 @@ async def start_pipeline(data: dict, project_id: str = ""):
f"branch collision for {repo}; disambiguated to unique branch {branch}"
)
# Insert task into DB
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(plane_id, work_item_id, repo, branch, "analysis", plane_id, name),
# Insert task into DB — ORCH-053 (AC-4): atomic anti-dup claim under a
# process-wide lock. If the F-2 reconciler and this live webhook race on the
# same plane_id, exactly one wins (created=True); the loser sees the existing
# task and returns WITHOUT creating a second branch / worktree / analyst job.
task_row, created = create_task_atomic(
plane_id, work_item_id, repo, branch, "analysis", name
)
conn.commit()
conn.close()
if not created:
logger.info(
f"start_pipeline: task for plane_id={plane_id} already exists "
f"(id={task_row['id']}, work_item_id={task_row.get('work_item_id')}), "
f"skipping duplicate creation"
)
return
task_id = task_row["id"]
# Create branch in Gitea
try:
@@ -523,20 +530,17 @@ async def start_pipeline(data: dict, project_id: str = ""):
logger.info(f"Task created: {work_item_id} ({name}), branch={branch}, stage=analysis")
# Launch analyst agent
# Launch analyst agent (task_id from the atomic create above).
try:
task_row = get_db().execute("SELECT id FROM tasks WHERE work_item_id=?", (work_item_id,)).fetchone()
if task_row:
task_id = task_row[0]
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: analysis\nTitle: {name}\n\nDescription:\n{description}"
)
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_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")
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: analysis\nTitle: {name}\n\nDescription:\n{description}"
)
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_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")
except Exception as e:
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")

View File

@@ -37,6 +37,10 @@ def _no_telegram(monkeypatch):
monkeypatch.setattr("src.webhooks.plane.send_telegram", _noop, raising=False)
monkeypatch.setattr("src.agents.launcher.send_telegram", _noop, raising=False)
monkeypatch.setattr("src.queue_worker.send_telegram", _noop, raising=False)
# ORCH-053: the reconciler binds send_telegram as a MODULE-LEVEL name
# (from .notifications import send_telegram), so the source patch alone would
# not intercept its unblock notification — patch it here too.
monkeypatch.setattr("src.reconciler.send_telegram", _noop, raising=False)
yield

View File

@@ -72,3 +72,96 @@ def test_merge_gate_settings_env_override(monkeypatch):
assert s.merge_lock_timeout_s == 90
assert s.merge_defer_delay_s == 5
assert s.merge_defer_max_attempts == 9
# ---------------------------------------------------------------------------
# ORCH-053 / TC-22: reconcile_* settings defaults + env override.
# ---------------------------------------------------------------------------
_RECONCILE_ENV = (
"ORCH_RECONCILE_ENABLED",
"ORCH_RECONCILE_INTERVAL_S",
"ORCH_RECONCILE_PLANE_ENABLED",
"ORCH_RECONCILE_GRACE_DEFAULT_S",
"ORCH_RECONCILE_GRACE_OVERRIDES_JSON",
"ORCH_RECONCILE_NOTIFY_UNBLOCK",
)
def test_reconcile_settings_defaults(monkeypatch):
"""TC-22 / AC-13: documented defaults when no env is set."""
for name in _RECONCILE_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.reconcile_enabled is True
assert s.reconcile_interval_s == 120
assert s.reconcile_plane_enabled is True
assert s.reconcile_grace_default_s == 600
assert s.reconcile_grace_overrides_json == ""
assert s.reconcile_notify_unblock is True
def test_reconcile_settings_env_override(monkeypatch):
"""TC-22 / AC-13: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_RECONCILE_ENABLED", "false")
monkeypatch.setenv("ORCH_RECONCILE_INTERVAL_S", "300")
monkeypatch.setenv("ORCH_RECONCILE_PLANE_ENABLED", "false")
monkeypatch.setenv("ORCH_RECONCILE_GRACE_DEFAULT_S", "900")
monkeypatch.setenv("ORCH_RECONCILE_GRACE_OVERRIDES_JSON", '{"development": 300}')
monkeypatch.setenv("ORCH_RECONCILE_NOTIFY_UNBLOCK", "false")
s = Settings()
assert s.reconcile_enabled is False
assert s.reconcile_interval_s == 300
assert s.reconcile_plane_enabled is False
assert s.reconcile_grace_default_s == 900
assert s.reconcile_grace_overrides_json == '{"development": 300}'
assert s.reconcile_notify_unblock is False
# ---------------------------------------------------------------------------
# ORCH-058 / TC-13: image-freshness settings defaults + env override.
# ---------------------------------------------------------------------------
_FRESH_ENV = (
"ORCH_IMAGE_FRESHNESS_ENABLED",
"ORCH_IMAGE_FRESHNESS_REPOS",
)
def test_image_freshness_settings_defaults(monkeypatch):
"""TC-13 / AC-9: kill-switch ON by default, empty CSV (self-hosting only)."""
for name in _FRESH_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.image_freshness_enabled is True
assert s.image_freshness_repos == ""
def test_image_freshness_settings_env_override(monkeypatch):
"""TC-13 / AC-9: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_IMAGE_FRESHNESS_ENABLED", "false")
monkeypatch.setenv("ORCH_IMAGE_FRESHNESS_REPOS", "orchestrator,enduro-trails")
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

View File

@@ -101,7 +101,8 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass},
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
# Spy: the deploy launcher must never run on the staging->deploy edge.
initiate = MagicMock()

View File

@@ -39,9 +39,56 @@ def test_tc14_hook_retag_branch_present():
assert 'SOURCE_IMAGE="${SOURCE_IMAGE:-}"' in text
# Build-once retag branch present; the hook never runs `docker build`.
assert 'docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"' in text
# No EXECUTABLE `docker build` line (comments mentioning it are fine).
# No EXECUTABLE `docker build` line on the PROD path (comments are fine).
# ORCH-058: the only build allowed is the staging-freshness rebuild,
# which is explicitly tagged with `--build-arg GIT_SHA` (Strategy A).
# Executable lines only: drop comments and `log "..."` strings that merely
# mention "docker build" in human-readable diagnostics.
exec_lines = [
ln.strip() for ln in text.splitlines()
if ln.strip() and not ln.strip().startswith("#")
if ln.strip()
and not ln.strip().startswith("#")
and not ln.strip().startswith("log ")
]
assert not any("docker build" in ln for ln in exec_lines)
for ln in exec_lines:
if "docker build" in ln:
assert "--build-arg GIT_SHA" in ln, (
f"unexpected docker build on prod retag path: {ln}"
)
# ---------------------------------------------------------------------------
# ORCH-058 TC-06: build_deploy_command threads EXPECTED_REVISION (Strategy B)
# ---------------------------------------------------------------------------
def test_tc06_deploy_command_passes_expected_revision(monkeypatch):
"""When image-freshness is active, the prod hook receives EXPECTED_REVISION."""
from src import image_freshness
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(
self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging"
)
monkeypatch.setattr(
image_freshness, "expected_revision", lambda repo, branch: "abc123def456"
)
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-058", "feature/ORCH-058-x")
remote = cmd[-1]
assert "EXPECTED_REVISION=abc123def456" in remote
def test_tc06_no_expected_revision_when_inactive(monkeypatch):
"""When image-freshness resolves to no SHA, EXPECTED_REVISION is omitted."""
from src import image_freshness
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_user", "slin")
monkeypatch.setattr(self_deploy.settings, "deploy_ssh_host", "mva154")
monkeypatch.setattr(
self_deploy.settings, "deploy_prod_source_image", "orchestrator-orchestrator-staging"
)
monkeypatch.setattr(image_freshness, "expected_revision", lambda repo, branch: "")
cmd = self_deploy.build_deploy_command("orchestrator", "ORCH-058", "feature/ORCH-058-x")
remote = cmd[-1]
assert "EXPECTED_REVISION=" not in remote

View File

@@ -27,6 +27,12 @@ def test_tc03_exit2_rollback_also_failed_maps_to_failed():
assert map_exit_code_to_status(2) == "FAILED"
def test_tc09_provenance_fail_closed_exit1_maps_to_failed():
"""ORCH-058 TC-09: the Strategy-B hook fail-close uses `exit 1`; that must map
to FAILED so the existing БАГ-8 rollback path triggers (prod never left stale)."""
assert map_exit_code_to_status(1) == "FAILED"
def test_other_exit_codes_map_to_failed():
for code in (3, 127, 255, -1):
assert map_exit_code_to_status(code) == "FAILED"

View File

@@ -0,0 +1,184 @@
"""ORCH-058 TC-07/08: static + caller-contract guarantees of the provenance plumbing.
These assert the *shape* of the deploy artefacts that can't be unit-tested by
running them (they shell out to docker/ssh on the host):
* TC-07 — the deploy hook fail-closes BEFORE `docker tag` when the staging
image's git-revision label != EXPECTED_REVISION (exit 1), and the
new `--build-staging` rebuild mode (a) stamps GIT_SHA into the image,
(b) uses $BUILD_CONTEXT as the build context, (c) recreates 8501 +
health-checks, (d) runs staging_check against the FRESH image
(Strategy A step 3, AC-4), and (e) never recomputes GIT_SHA from $REPO.
* TC-08 — the Dockerfile declares `ARG GIT_SHA` and stamps it into the
`org.opencontainers.image.revision` OCI label (the anchor B reads).
* TC-09 — the caller↔hook contract: `rebuild_staging_image` invokes the hook
in `--build-staging` mode with BUILD_CONTEXT=<host-worktree>,
GIT_SHA=<validated sha>, and an EXPLICIT staging target (never prod).
"""
import pathlib
_ROOT = pathlib.Path(__file__).resolve().parents[1]
_HOOK = _ROOT / "scripts" / "orchestrator-deploy-hook.sh"
_DOCKERFILE = _ROOT / "Dockerfile"
# ---------------------------------------------------------------------------
# TC-07: hook fail-closed provenance guard + --build-staging rebuild mode
# ---------------------------------------------------------------------------
def test_tc07_hook_has_fail_closed_provenance_guard():
text = _HOOK.read_text(encoding="utf-8")
# The label key the hook inspects must be the OCI revision label.
assert 'REVISION_LABEL="org.opencontainers.image.revision"' in text
# EXPECTED_REVISION is read (default unset -> backward compatible).
assert 'EXPECTED_REVISION="${EXPECTED_REVISION:-}"' in text
# The guard must inspect the source image's label and normalise <no value>.
assert "docker image inspect --format" in text
assert '"<no value>"' in text
# Fail-closed: empty OR mismatch -> abort with exit 1.
assert '-z "$IMG_REV" || "$IMG_REV" != "$EXPECTED_REVISION"' in text
def test_tc07_provenance_guard_precedes_docker_tag():
"""The fail-closed `exit 1` must sit BEFORE the `docker tag` retag line."""
text = _HOOK.read_text(encoding="utf-8")
guard = text.index("$EXPECTED_REVISION")
retag = text.index('docker tag "$SOURCE_IMAGE" "$TARGET_IMAGE"')
assert guard < retag, "provenance guard must run before the prod retag"
def test_tc07_build_staging_mode_stamps_git_sha():
text = _HOOK.read_text(encoding="utf-8")
# The new Strategy-A rebuild mode exists and is keyed on --build-staging.
assert '"${1:-}" == "--build-staging"' in text
# It rebuilds the staging image stamping the validated commit as a build-arg.
assert 'docker build --build-arg GIT_SHA="$GIT_SHA"' in text
def test_tc07_build_staging_uses_build_context_and_recreates_8501():
"""The rebuild must use $BUILD_CONTEXT as the docker build context and recreate
the staging service with a health-check (not a bare build)."""
text = _HOOK.read_text(encoding="utf-8")
# $BUILD_CONTEXT is the build context of the rebuild (validated worktree).
assert 'docker build --build-arg GIT_SHA="$GIT_SHA" -t "$TARGET_IMAGE" "$BUILD_CONTEXT"' in text
# Recreate the staging service on the fresh image (no-build) + health-check.
assert 'up -d --no-build "$TARGET_SERVICE"' in text
assert 'health_check 10 6 "build-staging-health"' in text
def test_tc07_build_staging_does_not_recompute_git_sha_from_repo():
"""Regression guard (root cause of the silent-stale-promote class): the
--build-staging mode must NOT derive GIT_SHA itself from the prod $REPO clone —
it must consume the GIT_SHA passed in by the caller (the validated commit)."""
text = _HOOK.read_text(encoding="utf-8")
# Anchor on the actual block guard (not the header comment mentions).
after = text[text.index('"${1:-}" == "--build-staging"'):]
assert 'GIT_SHA="${GIT_SHA:-}"' in after
assert "git rev-parse" not in after, "GIT_SHA must come from the caller, not the prod clone"
def test_tc07_build_staging_runs_staging_check_against_fresh_image():
"""Strategy A step 3 (ADR-001, AC-4): after recreate+health, the FRESH image is
validated by staging_check.py (not health-only). This is the P1 the reviewer
flagged: validate exactly the artefact later retagged to prod."""
text = _HOOK.read_text(encoding="utf-8")
# Anchor on the actual block guard (not the header comment mentions).
after = text[text.index('"${1:-}" == "--build-staging"'):]
# staging_check is invoked, inside the staging container, --mode stub by default.
assert "staging_check.py" in after
assert 'docker exec "$STAGING_CONTAINER"' in after
assert '--mode "$STAGING_CHECK_MODE"' in after
assert 'STAGING_CHECK_MODE="${STAGING_CHECK_MODE:-stub}"' in after
# The staging_check run must come AFTER the health-check (health gates readiness).
assert after.index('health_check 10 6 "build-staging-health"') < after.index("staging_check.py")
# ---------------------------------------------------------------------------
# TC-08: Dockerfile stamps the OCI revision label from a build-arg
# ---------------------------------------------------------------------------
def test_tc08_dockerfile_stamps_revision_label():
text = _DOCKERFILE.read_text(encoding="utf-8")
assert "ARG GIT_SHA" in text
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
# ---------------------------------------------------------------------------
def test_tc09_rebuild_staging_image_passes_validated_context_and_staging_target(monkeypatch):
"""`rebuild_staging_image` must invoke the hook `--build-staging` over ssh with
BUILD_CONTEXT=<host-worktree>, GIT_SHA=<validated sha>, and an EXPLICIT staging
target (service/port/profile/container) — never the prod 8500 target. The absence
of this contract test is what hid the earlier P0s (review P2)."""
import src.image_freshness as imgf
captured = {}
class _FakeCompleted:
returncode = 0
stdout = ""
stderr = ""
def _fake_run(cmd, *a, **kw):
captured["cmd"] = cmd
return _FakeCompleted()
monkeypatch.setattr(imgf, "_ssh_target", lambda: "slin@host")
monkeypatch.setattr(imgf, "_host_worktree_path",
lambda repo, branch: "/home/slin/repos/_wt/orchestrator/feature_X")
monkeypatch.setattr(imgf.subprocess, "run", _fake_run)
ok, msg = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123def456")
assert ok, msg
cmd = captured["cmd"]
assert cmd[0] == "ssh"
inner = cmd[-1] # the remote shell command string
# Validated commit + validated worktree as build context.
assert "GIT_SHA=abc123def456" in inner
assert "BUILD_CONTEXT=/home/slin/repos/_wt/orchestrator/feature_X" in inner
# Explicit STAGING target — never the prod 8500 service/port.
assert "TARGET_SERVICE=orchestrator-staging" in inner
assert "TARGET_PORT=8501" in inner
assert "COMPOSE_PROFILE=staging" in inner
assert "STAGING_CONTAINER=orchestrator-staging" in inner
assert "orchestrator-orchestrator-staging" in inner # staging TARGET_IMAGE
assert "--build-staging" in inner
# Hard safety: the prod service/port must NOT leak into the staging rebuild.
assert "TARGET_PORT=8500" not in inner
assert "TARGET_SERVICE=orchestrator " not in inner
def test_tc09_rebuild_staging_image_no_ssh_host_fails_closed(monkeypatch):
"""No ssh host configured -> never-raise, fail-closed (False), no command run."""
import src.image_freshness as imgf
monkeypatch.setattr(imgf, "_ssh_target", lambda: None)
ok, reason = imgf.rebuild_staging_image("orchestrator", "feature/ORCH-058", "abc123")
assert ok is False
assert "ssh host" in reason

View File

@@ -90,6 +90,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(

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