Compare commits

..

81 Commits

Author SHA1 Message Date
01684a89df fix(docker): drop COPY of gitignored data/ so staging image builds from a worktree
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
The staging-image rebuild (check_staging_image_fresh, ORCH-058) uses the task
git worktree as the docker build context. `data/` is gitignored (runtime SQLite
DB + backups) so it is absent in every worktree -> `COPY data/ ./data/` failed
the build (rc=1) -> deploy-staging rolled back to development (the loop ORCH-061
targets, surfaced one step later once the C9a/C9b waiver let the pipeline reach
the rebuild). The DB always arrives via the compose bind mount, so baking it in
was pointless (and leaked a stale host DB into the image).

Replace `COPY data/ ./data/` with `RUN mkdir -p /app/data` and add a static
regression guard asserting the Dockerfile never COPYs a gitignored path.

Refs: ORCH-061

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 13:39:02 +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
c0bcb544cf tester(ET): auto-commit from tester run_id=201
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 15s
2026-06-06 21:07:35 +00:00
2be39b398b reviewer(ET): auto-commit from reviewer run_id=199 2026-06-06 21:07:35 +00:00
d79defeadd fix(deploy): clear stale self-deploy markers on rollback; document env
Re-deploy after a FAILED prod deploy wedged the task on `deploy`: the
sentinel markers (approve-requested/initiated/result) are keyed by the
stable work_item_id, so after the БАГ-8 rollback (deploy -> development)
and a developer fix, Phase B's idempotency-guard saw a STALE `initiated`
and became a no-op — the detached hook never re-launched and the
finalizer was never enqueued. Add self_deploy.clear_state (never-raise,
idempotent) and call it on the check_deploy_status FAILED rollback and at
the start of Phase A, so every fresh prod-deploy pass starts clean.

Also document the new ORCH_SELF_DEPLOY_* / ORCH_DEPLOY_* descriptors in
the canonical .env.example (CLAUDE.md rule #8, ТЗ §2.6), modelled on the
ORCH-043 merge-gate block (placeholders only, secrets not committed).

Contracts untouched: STAGE_TRANSITIONS, QG_CHECKS, _parse_deploy_status,
БАГ-8, merge-gate.

Refs: ORCH-036
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:35 +00:00
9f43e6a0ae reviewer(ET): auto-commit from reviewer run_id=195 2026-06-06 21:07:35 +00:00
10f2a39a58 feat(deploy): build-once SOURCE_IMAGE retag in hook + deploy-stage docs
Add the optional, backward-compatible SOURCE_IMAGE branch to
orchestrator-deploy-hook.sh: when set, retag the staging-validated image
onto TARGET_IMAGE (docker tag) before `up -d --no-build` instead of
rebuilding — guarantees prod runs the exact artefact that passed staging
(AC-7 / TC-14). Unset -> prior behaviour; exit-code contract (0/1/2) and
health-loop untouched.

Update golden-source docs (AC-13): rewrite deployer.md `deploy` stage from
"paper SUCCESS" to the executable self-deploy (Phase A/B/C, no self-restart
from inside the container) and add the ORCH-036 CHANGELOG entry.

Refs: ORCH-036

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:35 +00:00
63187ff102 developer(ET): auto-commit from developer run_id=192 2026-06-06 21:07:35 +00:00
5c5525548d architect(ET): auto-commit from architect run_id=190 2026-06-06 21:07:35 +00:00
0d0cd6e281 analyst(ET): auto-commit from analyst run_id=189 2026-06-06 21:07:35 +00:00
480b203a9d docs: init ORCH-036 business request 2026-06-06 21:07:35 +00:00
7705552f08 docs(ORCH-036): staging gate log — staging_status SUCCESS (10/10 PASS)
Re-run of deploy-staging gate (merge-gate defer cycle). Canonical
staging_check.py (mode=stub) ran inside orchestrator-staging (8501);
all 10 checks passed (exit 0). No prod (8500) container touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:07:20 +00: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
d43603b224 docs(ORCH-053): deploy gate log — deploy_status SUCCESS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:04:04 +00:00
682ae09316 docs(ORCH-036): staging gate SUCCESS log (10/10 checks PASS)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:58:12 +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
434bd6243d docs(ORCH-053): staging gate SUCCESS log
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:55:10 +00:00
c21a279565 Merge pull request 'feat(merge-gate): auto-rebase onto current main + re-test + serialise merges (ORCH-043)' (#54) from feature/ORCH-043-merge-gate-auto-rebase-re-test into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 21:24:33 +03:00
d9afb3a10d docs(ORCH-043): deploy gate log — deploy_status SUCCESS
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:45:13 +00:00
8447853db8 deployer(ET): auto-commit from deployer run_id=187
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
2026-06-06 17:41:23 +00:00
5dc5893a49 docs(ORCH-043): staging gate log — staging_status SUCCESS
Live staging-stand suite (scripts/staging_check.py, stub mode) ran inside
orchestrator-staging: 10/10 checks PASS, exit code 0. Merge-gate edge
(deploy-staging → deploy) cleared for ORCH-043.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:41:06 +00:00
581a8b595a tester(ET): auto-commit from tester run_id=186
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 15s
2026-06-06 17:38:38 +00:00
ba51aa17bc reviewer(ET): auto-commit from reviewer run_id=185
All checks were successful
CI / test (push) Successful in 19s
CI / test (pull_request) Successful in 18s
2026-06-06 17:37:05 +00:00
00d69d9e27 feat(merge-gate): auto-rebase onto current main + re-test + serialise merges
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 17s
Deterministic (no-LLM) sub-gate on the deploy-staging -> deploy edge that
catches a feature branch up to the CURRENT origin/main, re-tests the combined
tree, and serialises merges with a per-repo file lease — so two green parallel
branches can no longer break main (self-hosting safety for the orchestrator repo).

- src/merge_gate.py: branch_is_behind_main, auto_rebase_onto_main (push
  --force-with-lease ONLY the task branch, NEVER main), retest_branch, and a
  file merge-lease (atomic O_CREAT|O_EXCL, holder-aware release, stale reclaim).
  Strict never-raise contract; all git ops in the per-branch worktree.
- src/qg/checks.py: check_branch_mergeable composes the primitives under the
  lease; registered in QG_CHECKS. Conditional rollout (merge_gate_enabled /
  merge_gate_repos, default self-hosting only).
- src/stage_engine.py: sub-gate hook on deploy-staging (not a new stage). PASS ->
  advance; "merge-lock busy" -> DEFER (re-queue with available_at, anti-deadlock
  at max_concurrency=1, capped); conflict/red re-test -> rollback to development
  + developer retry (capped by MAX_DEVELOPER_RETRIES). Lease released on
  deploy->done / rollback / PR-merged webhook.
- src/db.py: enqueue_job(available_at_delay_s=...) for the defer (no schema change).
- src/webhooks/gitea.py: holder-aware lease release on PR-merged.
- src/config.py + .env.example: ORCH_MERGE_* settings.

Docs: README + adr-0006 (architect) already cover the design; CHANGELOG updated.
Tests: test_merge_gate.py, test_qg_merge_gate.py, test_merge_gate_race.py,
test_stage_engine.py::TestMergeGate, test_config.py, QG-registry snapshot.
Full suite: 535 passed.

Refs: ORCH-043

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:32:50 +00:00
ad1589084b architect(ET): auto-commit from architect run_id=183
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 17:16:00 +00:00
77e7205ce8 analyst(ET): auto-commit from analyst run_id=182
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 16:39:20 +00:00
445807dd90 docs: init ORCH-043 business request
All checks were successful
CI / test (push) Successful in 14s
2026-06-06 19:31:37 +03:00
39cb5dde70 Merge pull request 'fix(infra): ORCH-040 run containers as host uid 1000:1000 (not root)' (#53) from feature/ORCH-040-root-git into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 19:26:35 +03:00
7b748b7ac5 docs(ORCH-040): deploy gate log — deploy_status SUCCESS
Self-hosting deploy verdict: artifact validated (staging gate green, compose
user=1000:1000 with МИНА 1 group_add intact). Prod cut-over handed to Owner
(P-1…P-4 + deploy hook) — in-task prod restart not performed by design.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:11:08 +00:00
80275a3336 docs(ORCH-040): staging gate log — staging_status SUCCESS (10/10)
Staging check suite passed 10/10 (exit 0), run canonically inside
orchestrator-staging via the Docker Engine API (docker exec equivalent).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 15:08:50 +00:00
135 changed files with 14305 additions and 48 deletions

View File

@@ -17,3 +17,102 @@ ORCH_DB_PATH=/app/data/orchestrator.db
# one is sent silently to the BOTTOM of the chat (deleteMessage + sendMessage +
# repoint). One card per task in both modes. Any value other than "bump" -> edit.
ORCH_TRACKER_MODE=edit
# ORCH-043: merge-gate (auto-rebase onto current origin/main + re-test + merge-lock)
# on the deploy-staging -> deploy edge. Deterministic sub-gate (no LLM) that catches
# the branch up to the CURRENT origin/main, re-tests it, and serialises merges so two
# green parallel branches can't break main.
# ENABLED -> global kill-switch (false -> whole gate is a no-op pass).
# REPOS -> CSV of repos where the gate is REAL; empty -> only the self-hosting
# repo (orchestrator); other repos -> conditional no-op (mirrors ORCH-35).
# RETEST_TIMEOUT_S -> wall-clock budget for the post-rebase re-test.
# RETEST_TARGET -> pytest target for the re-test.
# LOCK_TIMEOUT_S -> max merge-lease age before a stale lease is reclaimed.
# DEFER_DELAY_S -> delay before re-running the gate when the lock is busy.
# DEFER_MAX_ATTEMPTS -> defer retries before escalation (avoids livelock).
ORCH_MERGE_GATE_ENABLED=true
ORCH_MERGE_GATE_REPOS=
ORCH_MERGE_RETEST_TIMEOUT_S=600
ORCH_MERGE_RETEST_TARGET=tests/
ORCH_MERGE_LOCK_TIMEOUT_S=300
ORCH_MERGE_DEFER_DELAY_S=60
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
# deterministic phases (A: request approve, B: human Approved -> detached deploy,
# C: finalizer maps hook exit-code -> deploy_status). Non-self repos: unchanged
# synchronous ssh deploy. SECRETS / host paths live ONLY on the host — do NOT commit.
# SELF_DEPLOY_ENABLED -> global kill-switch (false -> legacy synchronous deploy for all).
# SELF_DEPLOY_REPOS -> CSV of repos where Phase A/B/C is REAL; empty -> only the
# self-hosting repo (orchestrator); others -> no-op (mirrors ORCH-35).
# DEPLOY_REQUIRE_MANUAL_APPROVE -> require a human Plane "Approved" before the prod
# deploy (true on rollout; full auto is ORCH-54).
# DEPLOY_FINALIZE_DELAY_S -> delay before the first/each finalize poll (>= hook+health).
# DEPLOY_FINALIZE_MAX_ATTEMPTS -> bounded finalize-defer budget (anti-livelock).
# DEPLOY_SSH_USER / DEPLOY_SSH_HOST -> ssh target for the host hook (DEPLOY_SSH_HOST
# empty -> detached deploy will NOT launch; set on the host).
# DEPLOY_HOOK_SCRIPT -> path to the hook ON THE HOST (relative to the repo).
# DEPLOY_HOST_REPO_PATH -> orchestrator clone path on the host.
# DEPLOY_PROD_SOURCE_IMAGE -> staging-validated image, retagged build-once (no rebuild).
# DEPLOY_PROD_TARGET_SERVICE / _PORT / _IMAGE / _COMPOSE_PROFILE -> prod compose profile.
# DEPLOY_PROD_PREV_IMAGE_FILE -> prod prev-image snapshot (separate from staging's).
ORCH_SELF_DEPLOY_ENABLED=true
ORCH_SELF_DEPLOY_REPOS=
ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE=true
ORCH_DEPLOY_FINALIZE_DELAY_S=90
ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS=10
ORCH_DEPLOY_SSH_USER=slin
ORCH_DEPLOY_SSH_HOST=
ORCH_DEPLOY_HOOK_SCRIPT=scripts/orchestrator-deploy-hook.sh
ORCH_DEPLOY_HOST_REPO_PATH=/home/slin/repos/orchestrator
ORCH_DEPLOY_PROD_SOURCE_IMAGE=orchestrator-orchestrator-staging
ORCH_DEPLOY_PROD_TARGET_SERVICE=orchestrator
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

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
@@ -73,13 +84,39 @@ On stage `deploy-staging` your job is to run the staging test suite and write a
---
## Stage: `deploy` (Production Deploy — ORCH-36, future)
On stage `deploy` your job is to perform (or simulate) the production deployment and write a machine-readable verdict to `docs/work-items/<work_item_id>/14-deploy-log.md` with frontmatter field `deploy_status: SUCCESS|FAILED`.
## Stage: `deploy` (Production Deploy — ORCH-36, executable self-deploy)
This stage is only reached if the staging gate (`deploy-staging`) passed with `staging_status: SUCCESS`.
The verdict contract is unchanged: `docs/work-items/<work_item_id>/14-deploy-log.md` with
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
⚠️ **CRITICAL**: Do NOT trigger real production deploys unless explicitly instructed. Real docker/SSH deploys are handled by `scripts/orchestrator-deploy-hook.sh` (ORCH-36).
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
`src/stage_engine.py` + `src/self_deploy.py`, NOT by you, and NOT by a "paper" `SUCCESS`:
- **Phase A** (entering `deploy`): the pipeline does NOT launch you. It sets the issue to an
approval-pending state and asks a human to flip the Plane status to **Approved**.
- **Phase B** (human Approved): the code launches a **detached host process**
(`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`) that retags the staging-validated
image onto the prod tag (build-once, `SOURCE_IMAGE`), restarts prod (8500) and health-checks.
The orchestrator NEVER restarts its own 8500 container from inside — that would kill the
worker mid-call.
- **Phase C** (finalizer): a deterministic finalizer-job in the NEW container reads the hook
exit-code, maps `0 → SUCCESS`, `1|2|other → FAILED`, writes `14-deploy-log.md` and drives the
existing contracts (`SUCCESS → done`, `FAILED → rollback to development`).
⚠️ **CRITICAL for self-hosting**: NEVER run `docker compose up -d orchestrator`, `--build`, or any
restart of 8500 from inside the agent. `deploy_status: SUCCESS` must reflect a REAL host health-ok,
never an LLM declaration. If you are ever launched on `deploy` for `orchestrator`, do nothing that
restarts prod — the host hook owns the restart.
### Non-self repos (e.g. `enduro-trails`) — unchanged synchronous ssh deploy
For non-self repos behaviour is unchanged: perform the production deployment (ssh to the project
host) and write the machine-readable verdict (`deploy_status: SUCCESS|FAILED`). Real docker/SSH
deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults are STAGING-safe).
---

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.

View File

@@ -5,6 +5,10 @@
## [Unreleased]
### Added
- **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, `<no value>`/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=<sha>`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`.
- **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid``scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<work_item_id>/`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`.
- **Sweeper потерянных webhook (реконсиляция застрявших стадий)** (ORCH-053): фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`), который устраняет тихое застревание задач, когда конвейер не двигается из-за потерянного события (502 на ребилде инстанса, отсутствие ретраев у Plane/Gitea, неразрезолвленный `sha→branch` — класс инцидента ORCH-044). Реконсилятор периодически (`reconcile_interval_s`) доигрывает пропущенный переход **через те же штатные гейты/обработчики**, что и webhook, не дублируя логику конвейера: **F-1 gate-side** (`reconcile_gate_once`) — для задач `stage≠done`, без активного job и `age(updated_at) ≥ grace_for_stage(stage)` делает read-only пред-оценку канонического QG стадии; зелёный → продвижение строго через неизменный `stage_engine.advance_stage(..., finished_agent=None)`; красный → тишина (спам нотификаций структурно невозможен — `advance_stage` на красном гейте не вызывается вовсе); `analysis` F-1 не трогает (человеческий гейт). **F-2 plane-side** (`reconcile_plane_once`) — опрос Plane API per-project (новый `plane_sync.list_issues_by_state`, курсорная пагинация, never-raise) и реплей In Progress / Approved / Rejected через существующие `webhooks.plane.handle_status_start` / `handle_verdict` (async-обработчики вызываются из sync-потока через `asyncio.run`). **F-3** — усиление `sha→branch` в `handle_ci_status`: при неразрезолвленном sha — БД-fallback по единственной development-задаче repo (`db.get_development_tasks_by_repo`; неоднозначность → не резолвим, ложного матча нет), `logger.debug``logger.info` для видимости потерянного CI-события. Анти-дубль на создании задачи (`db.create_task_atomic` под process-wide `threading.Lock`: SELECT-exists→INSERT, проигравший в гонке reconcile↔webhook не плодит второй task/branch/worktree/стартовый analyst-job). Старт/стоп в `main.lifespan` (после `worker.start()` / перед `worker.stop()`), restart-safe, never-raise на единицу работы. Наблюдаемость (F-4): при разблокировке — лог-строка `reconciler: <wi> <stage> разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`) и блок `reconcile` в `GET /queue`. Kill-switches: `ORCH_RECONCILE_ENABLED` (глобально), `ORCH_RECONCILE_PLANE_ENABLED` (гасит только F-2), `ORCH_RECONCILE_INTERVAL_S` (120), `ORCH_RECONCILE_GRACE_DEFAULT_S` (600), `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` (per-stage), `ORCH_RECONCILE_NOTIFY_UNBLOCK` (true). Схема БД и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) НЕ менялись. ADR `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`, глобальный `docs/architecture/adr/adr-0007-reconciler.md`. Тесты: `tests/test_reconciler.py`, `tests/test_reconciler_plane.py`, `tests/test_gitea_sha_resolve.py`, `tests/test_config.py`.
- **Merge-gate: авто-rebase на текущий `origin/main` + повторный прогон тестов + сериализация мержей** (ORCH-043): детерминированный (без LLM) суб-гейт на ребре `deploy-staging → deploy`, выполняемый ПЕРЕД мержем PR деплоером. Закрывает класс гонок «две зелёные ветки в одном репо ломают `main`»: пайплайн валидирует ветку против того `main`, от которого она ответвилась, а не против `main` в момент мержа — между «ветка зелёная» и «ветка смержена» параллельная задача может сдвинуть `main` (семантический конфликт: git мержит без текстового конфликта, но совмещённый `main` красный). Для self-hosting репозитория `orchestrator` это означало бы красный `main` инструмента, обслуживающего ВСЕ проекты. Новый модуль `src/merge_gate.py` (контракт «never raise», все git-операции — в per-branch worktree, ORCH-2/S-4): `branch_is_behind_main` (`git merge-base --is-ancestor origin/main HEAD`), `auto_rebase_onto_main` (rebase + `git push --force-with-lease` ТОЛЬКО ветки задачи — `main` НИКОГДА не пушится; текстовый конфликт → `rebase --abort` + чистый worktree), `retest_branch` (`python -m pytest <target>` в догнанном worktree, бюджет `merge_retest_timeout_s`), файловый merge-lease (`acquire_merge_lease`/`release_merge_lease`, атомарный `O_CREAT|O_EXCL`, holder-aware release, реклейм протухшего/битого лиза — без изменения схемы БД). Новый quality-gate `check_branch_mergeable` (`src/qg/checks.py`, зарегистрирован в `QG_CHECKS`) композирует примитивы под лизом: kill-switch/вне-области → no-op pass; lock занят → `(False, "merge-lock busy")` (сигнал DEFER, не код-фолт); ветка свежая → pass (лиз ДЕРЖИТСЯ до мержа); отстала → rebase → конфликт = fail+release, чисто → retest → зелёный = pass (лиз держится) / красный|timeout = fail+release. Интеграция в `src/stage_engine.py` (суб-гейт на `deploy-staging`, БЕЗ новой стадии в `STAGE_TRANSITIONS`): pass → advance на `deploy`; «merge-lock busy» → DEFER (повторная постановка деплоера на `deploy-staging` с задержкой `available_at`, анти-дедлок при `max_concurrency=1`, restart-safe счётчик по `task_content`, лимит `merge_defer_max_attempts` → block+Telegram); конфликт/красный retest → ROLLBACK на `development` + ретрай developer-а (кап `MAX_DEVELOPER_RETRIES`, без бесконечного баунса). Лиз освобождается на `deploy→done`, на rollback и по webhook смерженного PR (`src/webhooks/gitea.py`). Новый параметр `enqueue_job(..., available_at_delay_s=...)` (`src/db.py`) — отложенная постановка без изменения схемы. Условность раскатки (зеркало ORCH-35): `merge_gate_repos` (CSV) или по умолчанию только self-hosting `orchestrator`; глобальный kill-switch `merge_gate_enabled`. Новые настройки `ORCH_MERGE_GATE_ENABLED` (true), `ORCH_MERGE_GATE_REPOS` (""), `ORCH_MERGE_RETEST_TIMEOUT_S` (600), `ORCH_MERGE_RETEST_TARGET` (tests/), `ORCH_MERGE_LOCK_TIMEOUT_S` (300), `ORCH_MERGE_DEFER_DELAY_S` (60), `ORCH_MERGE_DEFER_MAX_ATTEMPTS` (5). ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`, глобальный `docs/architecture/adr/adr-0006-merge-gate.md`. Тесты: `tests/test_merge_gate.py`, `tests/test_qg_merge_gate.py`, `tests/test_merge_gate_race.py`, `tests/test_stage_engine.py::TestMergeGate`, `tests/test_config.py`.
- **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)``send_telegram(text, disable_notification=True)``set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`.
- **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты``## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`.
- **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after <T>s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.
@@ -25,6 +29,10 @@
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
- **Staging-образ снова собирается из git-воркти (петля `deploy-staging → development` на `docker build` rc=1)** (ORCH-061): после устранения ложного infra-FAIL (C9a/C9b) конвейер впервые дошёл до пересборки staging-образа (`check_staging_image_fresh`, ORCH-058) и упал на следующем шаге той же петли: `Dockerfile` содержал `COPY data/ ./data/`, но `data/` **в `.gitignore`** (рантайм-БД SQLite + бэкапы) → отсутствует в КАЖДОМ git-воркти. Staging-rebuild (`hook --build-staging`) использует **воркти задачи** как build-context, где `data/` нет → `docker build` падает с rc=1 (`BUILD-STAGING: docker build failed`) → откат `deploy-staging → development` → петля. Прод-сборка из основного чекаута (`/repos/orchestrator`, где `data/` существует как рантайм-каталог) маскировала дефект и заодно бесполезно «запекала» хостовую БД (~100 МБ бэкапов, утечка устаревшего состояния) в образ — рантайм всё равно перекрывает её bind-mount'ом compose (`./data:/app/data` прод, `./data/staging:/app/data` staging). Фикс: `COPY data/ ./data/` заменён на `RUN mkdir -p /app/data` — цель монтирования существует в образе, сборка не зависит от gitignore-каталога, SQLite создаёт `.db` сам. Контракты не тронуты: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_staging_image_fresh`/`check_staging_status`, OCI-лейбл `org.opencontainers.image.revision` (ORCH-058), exit-code хука; схема БД и compose-тома — без изменений. Регрессия-гард (статически, без docker): `tests/test_dockerfile_worktree_buildable.py``Dockerfile` не должен `COPY` ни одного gitignore-каталога (иначе сборка из воркти снова сломается).
- **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development``scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`.
- **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)**`developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053).
- **Re-deploy после отката больше не зависает на `deploy`; `.env.example` дополнен** (ORCH-036, review-fix): sentinel-маркеры самодеплоя (`approve-requested`/`initiated`/`result`) ключуются по стабильному `work_item_id`, поэтому при FAILED-деплое и откате БАГ-8 (`deploy → development`) они оставались на диске — после фикса developer-ом и повторного захода задачи на `deploy` Фаза B по idempotency-guard видела STALE `initiated` и становилась no-op: detached-хук не перезапускался, finalizer не ставился, задача висела на `deploy` навсегда (нарушался retry-контракт стадии, AC-4/AC-10; устаревший `result` к тому же был бы перечитан новым finalizer'ом). Добавлен `self_deploy.clear_state(repo, work_item_id)` (never-raise, idempotent, рекурсивное удаление `<repos_dir>/.deploy-state-<repo>/<wi>/`), вызывается в ветке БАГ-8-отката `check_deploy_status` FAILED (`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`) — каждый новый прод-деплой-проход стартует с чистого состояния. Отдельно: канонический `.env.example` (CLAUDE.md правило №8, ТЗ §2.6) дополнен полным блоком новых дескрипторов `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*` (плейсхолдеры, секреты не коммитятся) по образцу merge-gate ORCH-043. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` / `_parse_deploy_status` / БАГ-8 / merge-gate не тронуты. Тесты: `tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`, `tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`.
- **Контейнер и агенты бегут под uid хоста (1000:1000), не root** (ORCH-040): оба сервиса в `docker-compose.yml` (`orchestrator`, `orchestrator-staging`) получили `user: "1000:1000"` (slin) — устраняет корень проблемы, при которой Claude-CLI агенты, запускаемые через `subprocess.Popen` внутри root-контейнера, создавали все артефакты конвейера (git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) с владельцем `root:root` на хосте, из-за чего `git pull`/`git reset` под slin падали с `insufficient permission for adding an object` и каждый деплой требовал ручного `chown`. Теперь файлы сразу `slin:slin`. Доступ к docker.sock сохранён через `group_add: ["999"]` (МИНА 1 — НЕ удалена). SSH-маунт приведён к единому HOME агента: target `/root/.ssh``/home/slin/.ssh` (`/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`), синхронно с `HOME=/home/slin`, который launcher форсит в env Popen и git_env — устранён скрытый рассинхрон SSH-маунта с форсимым HOME. `src/agents/launcher.py` и `Dockerfile` НЕ менялись (numeric uid работает без записи в `/etc/passwd`; `safe.directory '*'` уже покрывает git над bind-mount). Требует host-prerequisites Owner (P-1…P-4, вне кода): блокер P-1 — `chown -R 1000:1000 /home/slin/.claude` для доступа uid 1000 к claude creds (иначе preflight заворачивает конвейер); прод-рестарт self — только в окно тишины (общий инстанс с enduro-trails), страховка — staging-гейт (adr-0003). ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`, глобальный `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`; INFRA.md обновлён (рантайм-uid, volumes/SSH target, host-prerequisites). Тесты: `tests/test_orch040_compose.py`.
- **Staging-чек B6 читает реестр из окружения работающего staging-инстанса** (ORCH-048): блок B6 «Registry: sandbox present, prod ET/ORCH absent» в `scripts/staging_check.py` давал **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции — единственный чек suite, который не ходил к инстансу по HTTP, а импортировал `src.projects` локально через host-path хак `sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`, строя реестр из `ORCH_PROJECTS_JSON` **process-env запускающего процесса**. При фактическом запуске деплоером с хоста переменная не задана → дефолт `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL → лишний откат `deploy-staging → development`. Решение (вариант «в», ADR-001): host-path хак удалён; suite канонически запускается ВНУТРИ контейнера `orchestrator-staging` через `docker exec … python3 /repos/orchestrator/scripts/staging_check.py` (`scripts/` доступен только через bind-mount, `import src.projects` резолвится через `PYTHONPATH=/app` из кода контейнера, env — `.env.staging`) → B6 читает реестр именно работающего инстанса, без HTTP-bootstrap и «курицы-яйца». Логика вердикта вынесена в чистую `_evaluate_b6(known) -> (passed, detail)` (инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`, формат detail сохранён) + `_known_project_ids_from_registry()` / `_run_b6()` с детерминированным FAIL при недоступности источника (не ложный PASS, не необработанное исключение). Синхронно обновлены `.openclaw/agents/deployer.md` (команда стадии через `docker exec`) и `docs/operations/STAGING_CHECK.md`. `src/projects.py`, `.env*` и прочие чеки A/B4/B5/C не тронуты; реестр `QG_CHECKS` и `check_staging_status` (ADR-0003) не менялись. ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`. Тесты: `tests/test_staging_check_b6.py`.
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md``result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.

View File

@@ -1,11 +1,34 @@
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-061: do NOT `COPY data/ ./data/`. `data/` is gitignored (runtime SQLite DB
# + backups), so it is ABSENT in every git worktree. The staging-image rebuild of
# ORCH-058 (`check_staging_image_fresh` / hook `--build-staging`) uses the task
# WORKTREE as the build context, where `data/` does not exist -> `COPY data/`
# fails the build (rc=1) -> deploy-staging rolls back to development (the loop this
# task fixes). It is also pointless: the DB always arrives via the compose bind
# mount (`./data:/app/data` prod, `./data/staging:/app/data` staging), which
# overrides anything baked in (and baking the host DB into the image leaks stale
# state). Just ensure the mount target exists; sqlite creates the .db file.
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,17 +35,138 @@ 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.
**Реестр 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 завершился»).
Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием:
- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`.
- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД.
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**.
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
(детерминированно, без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
прод-deployer выставляется approval-pending статус Plane + запрос approve
(Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status`
и merge-gate.
- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии
не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный
авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
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`.
### Свежесть артефакта 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`.
**Инвариант build-context (ORCH-061):** staging-rebuild собирает образ из **git-воркти**
задачи, а воркти содержит только git-tracked файлы. Поэтому `Dockerfile` НЕ должен
`COPY` ни одного gitignore-пути — иначе `docker build` падает (rc=1) и `deploy-staging`
зацикливается на откате в `development`. В частности `data/` (рантайм-БД + бэкапы)
gitignore'нут и приходит исключительно через compose bind-mount (`./data:/app/data`),
поэтому образ лишь создаёт каталог монтирования (`RUN mkdir -p /app/data`), а не копирует
его. Гард — `tests/test_dockerfile_worktree_buildable.py`.
### 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.
- Deploy / deploy-staging FAILED → откат на `development`.
- Merge-gate FAIL (конфликт rebase / красный re-test, ORCH-043) → откат на `development` + retry; `merge-lock busy` → **defer** (не откат, dev-retry не тратится).
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
### Обогащение `task_desc` при заворотах (ORCH-046)
@@ -83,7 +205,7 @@ created → analysis → architecture → development → review → testing →
|--------|------|----------|
| GET | `/health` | health check |
| GET | `/status` | активные задачи (stage != done) |
| GET | `/queue` | очередь: counts + max_concurrency + последние jobs |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + последние jobs |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
@@ -97,4 +219,4 @@ created → analysis → architecture → development → review → testing →
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-05 (main `f1b3146`). Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py.*
*Актуально на 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).*

View File

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

View File

@@ -0,0 +1,53 @@
# adr-0006: Merge-gate — догон `main` + re-test + сериализация слияний
- **Статус:** proposed
- **Дата:** 2026-06-06
- **Задача:** ORCH-043
- **Детальный ADR:** `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`
## Контекст
Ветка валидируется относительно того `main`, из которого создана, а не относительно `main`
на момент слияния. Параллельная задача могла влиться раньше → **семантический конфликт
слияния** (git мержит без текстового конфликта, но `main` сломан). Для self-hosting это
красный `main` инструмента, обслуживающего все проекты. Слияние в `main` делает
deployer-агент в начале стадии `deploy`; замена механизма PR-merge — вне объёма.
## Решение
Детерминированный merge-gate (`check_branch_mergeable`, без LLM) на ребре
`deploy-staging → deploy`, ДО запуска deployer'а, который мержит. `STAGE_TRANSITIONS` не
меняется (минимальный blast-radius); в `QG_CHECKS` добавлен `check_branch_mergeable`.
- **Догон:** ветка отстаёт ⇔ `origin/main` не предок HEAD → `rebase origin/main` в worktree
+ `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт →
`rebase --abort` → откат на `development`.
- **Re-test:** `python -m pytest tests/` в worktree догнанной ветки, тайм-аут
`merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (BR-5):** файловый **merge-lease** на репо
(`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge.
Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer**
(re-enqueue deployer с задержкой через `available_at`), не rollback. Release — на
PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe.
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги
`merge_gate_enabled` / `merge_gate_repos` для поэтапного раската.
## Альтернативы
- **Новая стадия `merge-gate`** (кандидат B) — «пустая» стадия без агента не имеет триггера
(`advance_stage` срабатывает только на завершении агента/вебхуке); потребовала бы chaining
в движке (не restart-safe) или синтетический job-тип. Отклонено.
- **Перенос merge в детерминированный шаг оркестратора** (кандидат C) — запрещён объёмом
(замена механизма PR-merge вне scope). Отклонено.
- **Блокирующий lock** — дедлок при одном worker-слоте. Отклонено в пользу defer.
## Последствия
- Сценарий «две зелёные ветки ломают `main`» закрыт: re-test против актуального `main` +
сериализация слияний.
- Плата: merge-gate — «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); сериализация
опирается на PR-merged вебхук со страховкой реклеймом по возрасту; defer перепрогоняет
staging; длинный re-test держит worker-слот.
- Сквозное изменение конвейера → `arch:major-change`; прод-деплой ORCH-043 строго через
staging-гейт (8501).
## Связи
adr-0001 (`is_self_hosting_repo`), adr-0003 (условный staging-гейт — образец условности),
adr-0002 (очередь / `available_at` для defer), ORCH-2 (worktree-изоляция), ORCH-046
(дословный reason в `task_desc` при откате).

View File

@@ -0,0 +1,64 @@
# ADR-0007: Исполняемый самодеплой стадии `deploy` (Вариант B, ORCH-36)
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-036`.
## Контекст
Стадия `deploy` была «бумажной»: deployer-агент писал `deploy_status:` в
`14-deploy-log.md`, гейт `check_deploy_status` парсил вердикт и двигал
`deploy → done`. Реального деплоя не было. ORCH-36 делает стадию исполняемой для
self-hosting (`orchestrator`), сохраняя прежний ssh-путь для остальных репо.
Три ограничения формируют дизайн (детально — `docs/work-items/ORCH-036/06-adr/ADR-001`):
1. **Self-restart**: рестарт прод-контейнера 8500 убивает in-container процесс →
рестарт делает ВНЕШНИЙ host-процесс.
2. **Status-only verdict model**: approve = смена статуса Plane на `Approved`
(комментарии не управляют конвейером).
3. **Гонка гейта**: вердикт нельзя читать до завершения асинхронного хука.
## Решение
Для self-hosting стадия `deploy` исполняется в три фазы детерминированным кодом
(без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — для self + `deploy_require_manual_approve=true`
вместо запуска прод-deployer выставляется approval-pending статус Plane + запрос
approve (Plane-коммент + Telegram). Перехват в `advance_stage` на ребре
`deploy-staging → deploy` (после `check_staging_status` и merge-gate).
- **Фаза B (Plane → Approved)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → `orchestrator-deploy-hook.sh`
с прод-параметрами и build-once retag) и ставит **детерминированный finalizer-job**
с задержкой; маркер `initiated` — идемпотентность. Возврат БЕЗ advance.
- **Фаза C (finalizer)** — после рестарта новый контейнер дочитывает sentinel
`result` (exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет
`14-deploy-log.md`, вызывает `advance_stage(deploy, finished_agent="deployer")`
→ существующие контракты: `SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
### Ключевые инварианты (НЕ меняются)
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status` / `_parse_deploy_status`
(frontmatter only), откат БАГ-8, terminal-sync `deploy → done`, merge-gate (ORCH-43),
exit-code-контракт хука (0/1/2).
### Новое (сквозное)
- **Детерминированный job-kind** `deploy-finalizer` в очереди (reserved-agent, не
LLM): read-result | defer | map+write+advance. Зеркалит детерминизм merge-gate.
- **Approve-флаг** `deploy_require_manual_approve` (дефолт `true`; полный авто —
отдельная задача после набора метрик доверия, ORCH-54).
- **Build-once**: опциональный `SOURCE_IMAGE` retag в хуке (обратно совместимо).
- **Restart-safe состояние** деплоя — sentinel-файлы под
`<repos_dir>/.deploy-state-<repo>/<wi>/` (как merge-lease), БЕЗ миграции БД.
### Условность
Вся логика — только для `is_self_hosting_repo(repo)` (как ORCH-35). Прочие репо
деплоятся прежним синхронным ssh-путём агентом.
## Последствия
- `deploy_status: SUCCESS` доказан реальным health-ok; критический путь self-restart
детерминирован.
- Вводится новая под-компонента (finalizer job-handler) → изменение помечено
`arch:major-change`.
- Approve вписан в status-only модель: restart-safe, аудируемо, идемпотентно.
- На старте — обязательный ручной approve; молчаливых деплоев нет (Plane+Telegram).
## Связанные ADR
`adr-0003` (staging-gate), `adr-0006` (merge-gate), `adr-0005` (run-as-host-uid).
Детальный per-work-item: `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.

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

@@ -8,6 +8,8 @@
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".
@@ -16,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`.
@@ -29,6 +42,13 @@
| `TARGET_IMAGE` | `orchestrator-orchestrator-staging` | Имя образа для retag при rollback |
| `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.
@@ -55,6 +75,20 @@ PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
### Прод build-once (ORCH-036) — ретег staging-образа, без rebuild
Так прод-деплой запускается **автоматически** исполняемым самодеплоем (Фаза B: `ssh + setsid`, см. `INFRA.md`). Ключевое отличие — `SOURCE_IMAGE` указывает на провалидированный staging-образ, который ретегается на прод-тег:
```bash
SOURCE_IMAGE=orchestrator-orchestrator-staging \
TARGET_SERVICE=orchestrator \
TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator \
COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
### Ручной rollback staging
```bash

View File

@@ -75,6 +75,22 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
| `ORCH_SELF_DEPLOY_ENABLED` | ORCH-036 kill-switch исполняемого самодеплоя (true); false → legacy-путь для всех |
| `ORCH_SELF_DEPLOY_REPOS` | CSV репозиториев с реальным самодеплоем; пусто → только self-hosting `orchestrator` |
| `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` | требовать человеческий Plane «Approved» для прод-деплоя (true, безопасно) |
| `ORCH_DEPLOY_FINALIZE_DELAY_S` / `_MAX_ATTEMPTS` | задержка и бюджет defer'ов finalizer'а (Фаза C; 90 / 10) |
| `ORCH_DEPLOY_SSH_USER` / `_SSH_HOST` | куда запускается detached хост-деплой (Фаза B, `ssh user@host`) |
| `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`.
@@ -117,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: ORCH-36: Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)
Work Item ID: ORCH-036
## Description
TBD

View File

@@ -0,0 +1,109 @@
# BRD — ORCH-36: Исполняемый самодеплой (стадия deploy дёргает хост-хук, Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
## 1. Контекст и проблема
Стадия `deploy` конвейера сейчас **«бумажная»**. На ней deployer-агент (LLM) только
пишет `docs/work-items/<wi>/14-deploy-log.md` с `deploy_status: SUCCESS|FAILED`, а QG
`check_deploy_status` (`src/qg/checks.py:464`) парсит этот вердикт и пускает `deploy → done`.
**Реального docker-деплоя нет** — продакшен орка катается руками (Стрим).
Хост-хук `scripts/orchestrator-deploy-hook.sh` **уже существует** (ORCH-34) и умеет:
захват PREV_IMG → `git pull` → рестарт сервиса → health-check (10×6с = 60с) →
авто-rollback при провале health, с корректным exit-code. Дефолты — STAGING-безопасные;
прод включается через override env (`TARGET_SERVICE`, `TARGET_PORT`, `TARGET_IMAGE`,
`COMPOSE_PROFILE`).
**Главная мина (self-hosting):** прод-контейнер `orchestrator` (8500) — ОДИН на все
проекты, и в нём же бежит сам deployer-агент. Deployer не может синхронно рестартить
контейнер, в котором живёт (`docker compose up -d orchestrator` убьёт его процесс на
середине). Реальный рестарт self-репо обязан делать ВНЕШНИЙ хост-хук (вне контейнера),
который срабатывает ПОСЛЕ выхода агента. Рубильник — снаружи; орк только ИНИЦИИРУЕТ.
## 2. Цель
Превратить стадию `deploy` в РЕАЛЬНЫЙ самодеплой: после зелёного `deploy-staging`-гейта
конвейер вызывает хост-хук с прод-параметрами, хук промоутит образ в прод (8500) с
health-чеком и авто-rollback. Результат хука (exit-code) маппится в `deploy_status`.
**На старте — с ОБЯЗАТЕЛЬНЫМ ручным approve** (`DEPLOY_REQUIRE_MANUAL_APPROVE=true`):
прод не трогается без явного «go» Владельца.
## 3. Ценность для бизнеса
- Уходит последний ручной шаг конвейера (прод-деплой Стрим) → шаг к автономному внедрению (эпик ORCH-54).
- `deploy_status: SUCCESS` становится **доказанным** (реальный health-ok), а не декларацией LLM.
- Гарантия build-once: «что протестировали на staging — то и в проде» (тот же образ, без пересборки).
- Прод никогда не остаётся в нерабочем состоянии: авто-rollback + health-таймаут.
## 4. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Владелец (Слава/Стрим) | Контроль через ручной approve; уведомления о каждом промоуте/откате |
| Проект enduro-trails | Прод-орк не должен падать (общий инстанс) — групповой риск |
| Конвейер ORCH | Стадия `deploy` исполняемая, гейты не сломаны |
## 5. Объём (scope)
### В объёме
1. Исполнение реального прод-деплоя из стадии `deploy` через хост-хук (ssh / detached на хосте).
2. Обязательный ручной approve-гейт ПОСЛЕ зелёного staging и ДО прод-рестарта (флаг включён).
3. Маппинг exit-code хука → `deploy_status: SUCCESS|FAILED` (реальный, не бумажный).
4. Уведомления (Plane-коммент + Telegram) на каждый промоут и откат.
5. Build-once: перетегирование образа, прошедшего staging, без пересборки.
6. Обновление `deployer.md` и `INFRA.md` (документация = golden source).
7. Для НЕ-self репо (enduro-trails и др.) — деплой по ssh на их хост (поведение не ломается).
### Вне объёма (явно)
- **Включение полного авто** (`DEPLOY_REQUIRE_MANUAL_APPROVE: true → false`) — отдельная задача,
ТОЛЬКО после набора метрик доверия (см. §7). В этой задаче флаг НЕ выключается.
- Изменение `docker-compose.yml` без явной необходимости.
- Изменение стадий `STAGE_TRANSITIONS`, реестра QG, terminal-sync `deploy → done`.
- Прод-деплой в реальный бой во время разработки задачи (отладка — только на staging-цели хука).
## 6. Бизнес-требования
- **BR-1.** После зелёного `deploy-staging`-гейта стадия `deploy` РЕАЛЬНО собирает/перетегирует
образ, рестартит целевой сервис и проверяет health — не пишет бумажный SUCCESS.
- **BR-2.** Для self-репо `orchestrator` рестарт 8500 выполняется ВНЕШНИМ (detached/host)
процессом; deployer-агент НЕ убивает контейнер, в котором работает.
- **BR-3.** `deploy_status: SUCCESS` пишется ТОЛЬКО при health-ok хука; провал/health-fail →
`deploy_status: FAILED` → откат на `development` (как ORCH-35 staging-rollback, БАГ-8).
- **BR-4.** Ручной approve обязателен (флаг `true`): без явного «go» прод НЕ трогается.
- **BR-5.** Каждый промоут и откат уведомляет Владельца: Plane-коммент в задачу + Telegram.
«Молчаливых» деплоев нет.
- **BR-6.** Build-once: в прод идёт тот образ, что прошёл staging-гейт (перетег, не пересборка).
- **BR-7.** Staging-гейт (`check_staging_status`) остаётся обязательным предусловием прод-деплоя.
- **BR-8.** Прод никогда не остаётся в нерабочем состоянии — авто-rollback при провале health.
- **BR-9.** Существующие гейты и инварианты не ломаются: `check_deploy_status`,
`_parse_deploy_status`, rollback `deploy → development` (БАГ-8), terminal-sync `deploy → done`,
merge-gate (ORCH-43).
- **BR-10.** Документация (`deployer.md`, `INFRA.md`, `CHANGELOG.md`) обновлена в том же PR.
## 7. Критерии готовности к включению ПОЛНОГО авто (вне этой задачи)
Переключать `DEPLOY_REQUIRE_MANUAL_APPROVE: true → false` можно ТОЛЬКО когда закрыты ВСЕ 5:
1. ≥10 успешных промоутов подряд (staging зелёный → approve → прод поднялся, откат не нужен).
2. Zero false-negative: staging-гейт ни разу не пропустил битый деплой как «зелёный».
3. Авто-rollback проверен в бою (≥23 реальных срабатывания), recovery 100%, MTTR < 60с.
4. Ни одного «молчаливого» деплоя (каждый промоут/откат уведомил Владельца).
5. Период наблюдения ≥10 деплоев ИЛИ ≥2 недели без инцидентов в режиме manual-approve.
## 8. Риски
| Риск | Влияние | Митигация |
|------|---------|-----------|
| Падение прод-орка 8500 при self-деплое | Встаёт конвейер ВСЕХ проектов | Detached host-хук + health + авто-rollback; отладка на staging-цели |
| Deployer рестартит сам себя синхронно | Процесс агента убит на середине | BR-2: рестарт только внешним detached-процессом |
| Преждевременный `deploy_status: SUCCESS` (хук ещё не закончил) | Задача уходит в done при незавершённом деплое | Гейт читает РЕАЛЬНЫЙ исход хука (механизм — на дизайне) |
| Деплой без approve | Неконтролируемый прод-деплой | BR-4: approve-гейт блокирует до «go» |
| Пересборка вместо перетега | В прод уезжает не то, что тестировали | BR-6: build-once, `--no-build` + retag |
## 9. Связанные задачи
ORCH-7 (self-hosting), ORCH-21 (auto-rollback), ORCH-34 (хук готов), ORCH-35 (staging-гейт),
ORCH-43 (merge-gate в проде), ORCH-54 (эпик автономного внедрения).
Дизайн-референс: `tasks/orchestrator/DESIGN_STAGING_ENV.md §4/§7`.

View File

@@ -0,0 +1,136 @@
# ТЗ — ORCH-36: Исполняемый самодеплой (стадия deploy дёргает хост-хук, Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
> Документ фиксирует ТРЕБОВАНИЯ к изменениям (что и где). Конкретный механизм
> (ssh vs docker.sock vs detached nohup/systemd-run; механизм approve) выбирает
> архитектор в ADR (`06-adr/`). ТЗ задаёт границы и контракты, не реализацию.
## 1. Текущее устройство (as-is, разведано в коде)
- **Стадии** (`src/stages.py`): `… testing → deploy-staging → deploy → done`.
- `deploy-staging`: `agent=deployer`, `qg=check_staging_status` (запускается deployer при
выходе из `deploy-staging`, входе в `deploy`).
- `deploy`: `agent=None`, `qg=check_deploy_status` (агент НЕ запускается при выходе из `deploy`).
- **Вывод:** реальную работу стадии `deploy` делает deployer-агент, запущенный на переходе
`deploy-staging → deploy`. Он пишет `14-deploy-log.md`. Когда он завершается, `advance_stage`
с `current_stage=deploy` прогоняет `check_deploy_status` и двигает `deploy → done`.
- **QG** (`src/qg/checks.py`):
- `check_deploy_status:464``_parse_deploy_status:406` читает ТОЛЬКО `deploy_status:` из
YAML-frontmatter `14-deploy-log.md` (worktree → origin/main fallback → not found).
- `check_staging_status:580` — условный (реален только для self-hosting `orchestrator`).
- `is_self_hosting_repo()` (`:511`) — детектор self-репо.
- **Откаты/диспетчеризация** (`src/stage_engine.py`):
- `_handle_qg_failure_rollbacks:585` — ветка `deployer` + `check_deploy_status` FAILED →
откат `deploy → development`, `set_issue_blocked`, release merge-lease, Plane+Telegram.
- Terminal-sync `deploy → done` (`:281`) → `set_issue_done`, release merge-lease.
- merge-gate (ORCH-43) на ребре `deploy-staging → deploy`НЕ трогать.
- **Launcher** (`src/agents/launcher.py`):
- deployer-агент конфиг: `.task-deploy.md` / `.openclaw/agents/deployer.md` (`:180`).
- Пост-обработка: commit+push артефактов в worktree (`:506-558`).
- `exit_code != 0 && agent == deployer` → откат `deploy → development` (`:560-581`).
- **Хост-хук** (`scripts/orchestrator-deploy-hook.sh`, ORCH-34) — ГОТОВ: `--deploy`/`--rollback`,
параметризован env, дефолты STAGING; health 10×6с; авто-rollback; exit 0/1/2.
- **Agent (deployer.md)**: на стадии `deploy` сейчас пишет «бумажный» вердикт; в промпте маркер
«Real docker/SSH deploys are handled by scripts/orchestrator-deploy-hook.sh (ORCH-36)».
- **Топология** (`docs/operations/INFRA.md`): prod=8500 (`.env`), staging=8501 (`.env.staging`,
profile staging). Контейнер под uid 1000, доступ к docker.sock через gid 999.
## 2. Изменения по модулям (to-be)
### 2.1 `scripts/orchestrator-deploy-hook.sh` (донастройка прод-режима)
- Хук уже параметризован; требуется обеспечить **корректный прод-профиль вызова**:
`TARGET_SERVICE=orchestrator`, `TARGET_PORT=8500`, `TARGET_IMAGE=orchestrator-orchestrator`,
`COMPOSE_PROFILE` (для прод-сервиса — пустой/дефолтный, т.к. prod стартует без profile).
- **Build-once (BR-6):** деплой должен использовать образ, прошедший staging (перетег
staging-образа → прод-тег + `docker compose up -d --no-build`), а НЕ пересобирать. Если
текущий хук всегда `--no-build` и тянет `git pull` — уточнить в ADR, как гарантируется
идентичность артефакта staging↔prod (retag staging image, либо общий build-once шаг).
- `PREV_IMAGE_FILE` для прод — отдельный путь (например `.deploy-prev-image` без `-staging`),
чтобы не путать снапшоты prod/staging.
- Поведение `--rollback`, health-loop, exit-code (0=ok, 1=rolled back, 2=rollback тоже упал) —
НЕ менять контракт.
### 2.2 Approve-гейт (новое; место — на дизайне)
- Ввести флаг конфигурации `DEPLOY_REQUIRE_MANUAL_APPROVE` (bool, дефолт `true`).
- При `true`: перед вызовом прод-хука (после зелёного `deploy-staging`) конвейер ОСТАНАВЛИВАЕТСЯ
и ждёт явного «go» Владельца. Без «go» прод-хук НЕ вызывается.
- Механизм approve (выбрать ОДИН в ADR): Plane-коммент-триггер (по образцу `:approved:`
в `check_analysis_approved`) / Telegram-кнопка / signal-файл. Требование к механизму:
рестарт-safe (переживает перезапуск инстанса), идемпотентный, аудируемый.
- При `false` (вне этой задачи): approve-шаг пропускается — НЕ реализовывать выключение здесь,
только заложить ветку по флагу.
### 2.3 Триггер реального деплоя из стадии `deploy`
- На стадии `deploy` (для self-репо `orchestrator`) вместо/в дополнение к записи вердикта
агентом — ИНИЦИИРОВАТЬ внешний detached-процесс (host-хук), который выполнит
build-once+restart+health ПОСЛЕ выхода агента (BR-2: агент не рестартит сам себя).
- Маршрут вызова (на дизайне): ssh на хост (`DEPLOY_SSH_USER`/`DEPLOY_HOOK_SCRIPT`) ИЛИ
detached через docker.sock/nohup/systemd-run. Требование: процесс хука переживает выход
агента и завершение его сессии.
- Для **не-self** репо (enduro-trails): деплой по ssh на их хост (как раньше) — поведение не ломать.
### 2.4 Маппинг результата хука → `deploy_status`
- `deploy_status: SUCCESS` пишется в `14-deploy-log.md` ТОЛЬКО при exit-code хука = 0 (health-ok).
- exit-code ≠ 0 (1 = rolled back; 2 = rollback тоже упал) → `deploy_status: FAILED`.
- **Контракт `_parse_deploy_status` НЕ меняется** (читает `deploy_status: SUCCESS|FAILED` из
frontmatter). Меняется только КТО и КОГДА пишет этот вердикт — на основе реального исхода.
- **Гонка чтения гейта:** т.к. self-рестарт асинхронный (detached), гейт `check_deploy_status`
не должен прочитать вердикт ДО завершения хука. Механизм синхронизации (post-factum запись
лога/мердж в main / отложенный гейт) — спроектировать в ADR так, чтобы гейт читал РЕАЛЬНЫЙ
итог. Контракт чтения из worktree→origin/main (`_deploy_log_from_main`) можно переиспользовать.
### 2.5 Уведомления (BR-5)
- На промоут (старт прод-деплоя + успех) и на откат → `plane_add_comment(work_item_id, …)` +
`send_telegram(…)`. Переиспользовать существующие хелперы (`src/notifications.py`,
`src/plane_sync.py`). Никаких «молчаливых» деплоев.
### 2.6 Конфигурация (`src/config.py` / `.env.example` / `.env.staging.example`)
- Новый: `deploy_require_manual_approve: bool = True` (env `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE`).
- Прод-параметры хука: `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST`, `DEPLOY_HOOK_SCRIPT` (уже есть в
INFRA-карте) + прод-override `TARGET_SERVICE/PORT/IMAGE`. Прописать дескрипторы в `.env.example`
(значения — только на хосте, не коммитить).
- Условность по репо: реальный прод-деплой — только для self-hosting (`is_self_hosting_repo`),
как ORCH-35; прочие репо идут прежним ssh-путём.
### 2.7 Документация (BR-10, golden source)
- `.openclaw/agents/deployer.md` — раздел «Stage: deploy»: переписать с «бумажного SUCCESS» на
«стадия ВЫЗЫВАЕТ хук»; зафиксировать запрет синхронного рестарта 8500 и detached-путь self.
- `docs/operations/INFRA.md` — процедура прод-деплоя орка через хук + approve.
- `docs/operations/DEPLOY_HOOK.md` — обновить, если затронут контракт хука.
- `CHANGELOG.md` — запись о включении исполняемого деплоя (manual-approve).
- ADR в `docs/work-items/ORCH-036/06-adr/ADR-NNN-*.md` (создаёт архитектор).
## 3. API
- Изменений публичного HTTP API (`/health`, `/status`, `/queue`, `/webhook/*`) **не требуется**.
- Если approve реализуется через Plane-коммент — переиспользуется существующий webhook-путь
(`POST /webhook/plane`), новый endpoint не вводится. Если через signal-файл/Telegram —
внешний по отношению к HTTP API механизм. Решение — ADR.
## 4. Схема БД
- Изменения схемы **не требуются** для базового сценария (вердикт — в `14-deploy-log.md`;
approve-состояние желательно хранить рестарт-safe — допустимо через jobs/task_content или
signal-файл, без новой таблицы). Если архитектор сочтёт нужным поле статуса approve —
обосновать в ADR; по умолчанию — без миграции.
## 5. Требования к Quality Gates
- `check_deploy_status` и `_parse_deploy_status` — контракт чтения НЕ менять (frontmatter only).
- Откат `deploy → development` при `deploy_status: FAILED` (`stage_engine` БАГ-8) — сохранить.
- Terminal-sync `deploy → done` и release merge-lease — сохранить.
- merge-gate (`check_branch_mergeable`) на ребре `deploy-staging → deploy` — не затрагивать.
- `check_staging_status` остаётся обязательным предусловием (BR-7).
## 6. Артефакты pipeline
- Создаётся/обновляется: `docs/work-items/ORCH-036/14-deploy-log.md` (с РЕАЛЬНЫМ `deploy_status`).
- Обновляются по pipeline: `06-adr/ADR-NNN-*.md`, `12-review.md`, `13-test-report.md`,
`15-staging-log.md` (последующими агентами).
## 7. Нефункциональные требования
- **Безопасность self-deploy:** рестарт 8500 — только внешним рубильником; орк не может
необратимо убить себя.
- **Идемпотентность** хука и approve-механизма; **рестарт-safe** approve-состояние.
- **MTTR < 60с** при авто-rollback (health-loop хука 10×6с уже укладывается).
- **Отладка только на staging-цели** хука; реальный прод — лишь после approve.

View File

@@ -0,0 +1,97 @@
# Критерии приёмки — ORCH-36: Исполняемый самодеплой (Вариант B)
Work Item: ORCH-036
Stage: analysis
Автор: analyst
Дата: 2026-06-06
Формат: каждый критерий — проверяемое условие PASS/FAIL. Отладка и проверки
выполняются на **staging-цели хука** (8501); реальный прод (8500) — только после approve.
---
## AC-1. Стадия deploy исполняет реальный деплой (не бумажный)
- **PASS:** на стадии `deploy` (после зелёного `deploy-staging`) вызывается хост-хук,
который реально перетегирует образ, рестартит целевой сервис и выполняет health-check;
`deploy_status` отражает РЕАЛЬНЫЙ исход хука.
- **FAIL:** `deploy_status: SUCCESS` пишется без фактического рестарта/health (бумажный лог).
- **Проверка:** прогон на staging-цели хука; в логе хука видны retag + `up -d` + health-loop;
exit-code хука соответствует записанному `deploy_status`.
## AC-2. Self-репо: рестарт 8500 — внешним detached-процессом, агент себя не убивает
- **PASS:** для `orchestrator` рестарт 8500 выполняет процесс ВНЕ контейнера агента; deployer-агент
завершается штатно (exit 0), его процесс не убит рестартом контейнера.
- **FAIL:** deployer синхронно делает `docker compose up -d orchestrator` из контейнера и/или
агент падает/обрывается на середине из-за рестарта собственного контейнера.
- **Проверка:** симуляция на staging-цели; убедиться, что detached-процесс переживает выход агента.
## AC-3. deploy_status маппится из exit-code хука
- **PASS:** exit-code хука 0 → `deploy_status: SUCCESS`; exit-code ≠ 0 (1/2) → `deploy_status: FAILED`.
- **FAIL:** любой иной маппинг (например SUCCESS при exit 1).
- **Проверка:** unit-тест маппинга exit-code → вердикт; интеграционный прогон с искусственным
кодом возврата хука.
## AC-4. Провал деплоя → откат на development
- **PASS:** при `deploy_status: FAILED` задача откатывается `deploy → development`
(`set_issue_blocked`, Plane+Telegram), как в существующей ветке БАГ-8.
- **FAIL:** при FAILED задача уходит в `done` или зависает.
- **Проверка:** существующий контракт `stage_engine._handle_qg_failure_rollbacks` для
`deployer`+`check_deploy_status` сохранён и срабатывает.
## AC-5. Ручной approve обязателен и реально тормозит прод
- **PASS:** при `DEPLOY_REQUIRE_MANUAL_APPROVE=true` прод-хук НЕ вызывается до явного «go»;
после «go» — вызывается.
- **FAIL:** прод-хук дёргается без approve.
- **Проверка:** прогон без «go» — целевой сервис НЕ перезапущен (нет записи рестарта в логе хука,
не сменился uptime/контейнер); прогон с «go» — рестарт состоялся.
## AC-6. Уведомления о каждом промоуте и откате
- **PASS:** на старт/успех прод-деплоя и на откат приходят и Plane-коммент в задачу, и Telegram.
- **FAIL:** хотя бы один промоут/откат прошёл «молчаливо».
- **Проверка:** в Plane-задаче и в Telegram-чате присутствуют сообщения для каждого исхода.
## AC-7. Build-once: в прод идёт образ, прошедший staging
- **PASS:** прод-деплой использует тот же образ, что прошёл staging-гейт (retag + `--no-build`),
без пересборки.
- **FAIL:** прод-деплой пересобирает образ заново (артефакт может отличаться от протестированного).
- **Проверка:** sha/тег образа прод == образ, валидированный на staging; в логе нет `build`.
## AC-8. Staging-гейт остаётся обязательным предусловием
- **PASS:** прод-деплой недостижим без зелёного `check_staging_status` (`staging_status: SUCCESS`).
- **FAIL:** прод-хук можно вызвать при FAILED/отсутствующем staging-вердикте.
- **Проверка:** при `staging_status: FAILED` задача откатывается на development, до `deploy` не доходит.
## AC-9. Авто-rollback восстанавливает прод (симуляция битого деплоя)
- **PASS:** при симуляции битого деплоя на staging-цели health не проходит → хук авто-откатывает
на предыдущий образ → сервис снова healthy; exit-code = 1 (rolled back); MTTR < 60с.
- **FAIL:** сервис остаётся нерабочим после провала деплоя.
- **Проверка:** искусственно сломать health, прогнать хук, убедиться в восстановлении и exit 1.
## AC-10. Существующие инварианты не сломаны
- **PASS:** не изменены контракты `check_deploy_status` / `_parse_deploy_status`,
`STAGE_TRANSITIONS`, terminal-sync `deploy → done`, merge-gate (ORCH-43), rollback БАГ-8.
- **FAIL:** любой из перечисленных контрактов изменён/сломан.
- **Проверка:** существующие тесты deploy/staging/merge-gate зелёные; регресс-прогон `pytest tests/`.
## AC-11. Условность по репо (не-self не ломается)
- **PASS:** для не-self репо (enduro-trails) деплой идёт прежним ssh-путём; self-логика (detached,
approve, 8500) применяется только для `orchestrator`.
- **FAIL:** не-self репо затронуты self-специфичной логикой и ломаются.
- **Проверка:** `is_self_hosting_repo` корректно разводит пути; тест на не-self репо.
## AC-12. Флаг полного авто НЕ выключен в этой задаче
- **PASS:** `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true`; переключение в `false` не делается.
- **FAIL:** флаг выставлен в `false` в рамках задачи.
- **Проверка:** дефолт конфигурации = `true`; в коде/`.env.example` нет принудительного `false`.
## AC-13. Документация обновлена (golden source)
- **PASS:** обновлены `deployer.md` (стадия deploy = вызов хука), `INFRA.md` (процедура),
`CHANGELOG.md`; заведён ADR в `06-adr/`.
- **FAIL:** функционал изменён, документация — нет (Reviewer обязан вернуть REQUEST_CHANGES).
- **Проверка:** диффы документации присутствуют в том же PR.
---
## Definition of Done
Все AC-1…AC-13 в статусе PASS; `pytest tests/` зелёный; артефакты pipeline на месте;
прод (8500) во время разработки НЕ тронут (вся проверка — на staging-цели хука).

View File

@@ -0,0 +1,122 @@
work_item: ORCH-036
title: "Исполняемый самодеплой — стадия deploy дёргает хост-хук (Вариант B)"
stage: analysis
notes: >
Все тесты — на изолированном уровне (unit/integration с моками subprocess/ssh
и хука). Реальный прод (8500) НЕ трогается. Интеграционные прогоны хука — на
staging-цели. Хост-хук (bash) проверяется отдельным интеграционным сценарием с
поддельным health/exit-code; в pytest вызов хука мокается.
tests:
# --- exit-code -> deploy_status mapping (AC-1, AC-3) ---
- id: TC-01
type: unit
description: "Маппинг exit-code хука 0 -> deploy_status: SUCCESS"
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-02
type: unit
description: "Маппинг exit-code хука 1 (rolled back) -> deploy_status: FAILED"
module: tests/test_deploy_hook_mapping.py
expected: PASS
- id: TC-03
type: unit
description: "Маппинг exit-code хука 2 (rollback тоже упал) -> deploy_status: FAILED"
module: tests/test_deploy_hook_mapping.py
expected: PASS
# --- approve gate (AC-5, AC-12) ---
- id: TC-04
type: unit
description: "DEPLOY_REQUIRE_MANUAL_APPROVE дефолт == true в settings"
module: tests/test_deploy_approve.py
expected: PASS
- id: TC-05
type: integration
description: "Флаг true и нет 'go' -> прод-хук НЕ вызывается (subprocess/ssh не дёрнут)"
module: tests/test_deploy_approve.py
expected: PASS
- id: TC-06
type: integration
description: "Флаг true и есть 'go' -> прод-хук вызывается ровно один раз"
module: tests/test_deploy_approve.py
expected: PASS
# --- self vs non-self routing (AC-2, AC-11) ---
- id: TC-07
type: unit
description: "is_self_hosting_repo('orchestrator') == True; иной репо -> False (не регрессировал)"
module: tests/test_deploy_routing.py
expected: PASS
- id: TC-08
type: integration
description: "self-репо orchestrator: рестарт инициируется detached/host-процессом, не синхронно из агента"
module: tests/test_deploy_routing.py
expected: PASS
- id: TC-09
type: integration
description: "не-self репо (enduro-trails): деплой идёт прежним ssh-путём, self-логика не применяется"
module: tests/test_deploy_routing.py
expected: PASS
# --- rollback on FAILED (AC-4) ---
- id: TC-10
type: integration
description: "deploy_status: FAILED -> откат deploy->development, set_issue_blocked, release merge-lease"
module: tests/test_deploy_rollback.py
expected: PASS
# --- staging precondition preserved (AC-8) ---
- id: TC-11
type: integration
description: "staging_status: FAILED -> до стадии deploy не доходит (откат на development)"
module: tests/test_staging_precondition.py
expected: PASS
# --- notifications (AC-6) ---
- id: TC-12
type: integration
description: "Успешный промоут -> и Plane-коммент, и Telegram отправлены"
module: tests/test_deploy_notifications.py
expected: PASS
- id: TC-13
type: integration
description: "Откат -> и Plane-коммент, и Telegram отправлены (нет молчаливого деплоя)"
module: tests/test_deploy_notifications.py
expected: PASS
# --- build-once (AC-7) ---
- id: TC-14
type: integration
description: "Прод-деплой использует образ staging (retag, без build) — нет шага docker build"
module: tests/test_deploy_build_once.py
expected: PASS
# --- regression: unchanged gate contracts (AC-10) ---
- id: TC-15
type: unit
description: "_parse_deploy_status: SUCCESS->(True), FAILED->(False), нет frontmatter->(False) — контракт цел"
module: tests/test_qg_checks.py
expected: PASS
- id: TC-16
type: unit
description: "STAGE_TRANSITIONS deploy->done и agent/qg deploy не изменены"
module: tests/test_stages.py
expected: PASS
- id: TC-17
type: integration
description: "terminal-sync deploy->done (set_issue_done + release merge-lease) сохранён"
module: tests/test_deploy_terminal_sync.py
expected: PASS
- id: TC-18
type: integration
description: "merge-gate на ребре deploy-staging->deploy не затронут (регресс ORCH-43 зелёный)"
module: tests/test_merge_gate.py
expected: PASS
# --- auto-rollback hook behavior (AC-9) ---
- id: TC-19
type: integration
description: "Симуляция битого деплоя на staging-цели: health fail -> авто-rollback -> healthy, exit 1, MTTR<60с"
module: tests/test_deploy_hook_rollback_sim.py
expected: PASS

View File

@@ -0,0 +1,184 @@
# ADR-001: Исполняемый самодеплой — стадия `deploy` дёргает хост-хук (Вариант B)
Work Item: ORCH-036
Stage: architecture
Автор: architect
Дата: 2026-06-06
## Статус
Accepted
## Контекст
Стадия `deploy` сейчас «бумажная»: deployer-агент (LLM) пишет в `14-deploy-log.md`
`deploy_status: SUCCESS|FAILED`, а гейт `check_deploy_status` (`src/qg/checks.py:464`)
парсит этот вердикт и двигает `deploy → done`. Реального docker-деплоя нет (прод
катается руками). BRD ORCH-36 требует превратить стадию в РЕАЛЬНЫЙ самодеплой с
обязательным ручным approve, build-once и авто-rollback (BR-1…BR-10).
Три твёрдых ограничения, разведанных в коде, определяют дизайн:
1. **Self-restart (BR-2).** Прод-контейнер `orchestrator` (8500) — ОДИН на все
проекты, и в нём же исполняется deployer. `docker compose up -d orchestrator`
из контейнера убьёт процесс агента/воркера на середине. Реальный рестарт обязан
делать ВНЕШНИЙ процесс на хосте, переживающий гибель контейнера.
2. **Status-only verdict model.** Комментарии Plane НЕ управляют конвейером —
механизм `:approved:`/`:rejected:` был удалён (`src/webhooks/plane.py:544`,
bug-3 «echo self-hit»). Единственный человеческий гейт — **смена статуса Plane
на `Approved`** (`handle_verdict``_try_advance_stage``advance_stage`).
3. **Гонка чтения гейта.** Так как реальный рестарт асинхронный и убивает контейнер,
`check_deploy_status` нельзя выполнять на выходе агента — вердикта ещё нет; его
преждевременное чтение → ложный FAILED → ложный откат.
Контракты, которые НЕ меняются (BR-9, AC-10): `STAGE_TRANSITIONS`,
`check_deploy_status` / `_parse_deploy_status` (frontmatter only), откат БАГ-8
(`deploy → development`), terminal-sync `deploy → done`, merge-gate (ORCH-43),
exit-code-контракт хука (0/1/2).
## Решение
Деплой стадии `deploy` для self-hosting (`orchestrator`) разбивается на **три фазы**,
оркеструемые детерминированным кодом (без LLM в критическом пути self-restart). Для
НЕ-self репо (enduro-trails и пр.) поведение НЕ меняется — прежний синхронный
ssh-деплой агентом.
### Условность по репо
Вся новая логика гейтится `is_self_hosting_repo(repo)` (как ORCH-35). Не-self репо
идут существующим путём: deployer-агент на стадии `deploy` делает ssh-деплой
синхронно, пишет `14-deploy-log.md`, гейт срабатывает на выходе агента.
### Фаза A — запрос approve (вход в `deploy`)
В `advance_stage` на ребре `deploy-staging → deploy` (ПОСЛЕ зелёного
`check_staging_status` и merge-gate ORCH-43), для self-hosting + `deploy_require_
manual_approve=true`:
- **НЕ** ставить в очередь прод-deployer (перехватить штатный
`enqueue_job(get_agent_for_stage("deploy-staging"))`);
- выставить issue в approval-pending статус (паттерн `set_issue_in_review`),
написать Plane-коммент «approve для прод-деплоя» + Telegram (BR-5);
- записать restart-safe маркер `approve-requested` (sentinel-файл, см. ниже).
Задача остаётся на стадии `deploy` и ждёт человека. `STAGE_TRANSITIONS` не меняется.
При `deploy_require_manual_approve=false` (вне объёма, флаг НЕ выключается в ORCH-36 —
AC-12) Фаза A сразу переходит к Фазе B без человеческого гейта. Структурная ветка
закладывается, но дефолт `true`.
### Фаза B — инициация деплоя (смена статуса Plane → Approved)
Человек ставит issue в `Approved`. `handle_verdict(approved=True)`
`_try_advance_stage``advance_stage(current_stage="deploy", finished_agent=None)`.
Новая ветка-перехват в `advance_stage`:
- условие: `current_stage=="deploy"` И `finished_agent is None` (человеческий путь)
И self-hosting И approve-флаг И маркер `initiated` ОТСУТСТВУЕТ;
- действие: запустить **внешний detached host-процесс** (см. ниже) и поставить в
очередь детерминированный **finalizer-job** с задержкой; записать маркер
`initiated` (идемпотентность: повторный Approved не запускает деплой дважды);
Plane-коммент «прод-деплой стартовал» + Telegram (BR-5);
- **вернуться БЕЗ advance** (НЕ запускать `check_deploy_status` — вердикта ещё нет).
Дискриминатор `finished_agent` разводит Фазу B (человек, `None`) и Фазу C
(finalizer, `"deployer"`), поэтому повторное использование `advance_stage` безопасно.
### Фаза C — фиксация вердикта (детерминированный finalizer)
Finalizer-job (claim'ится воркером уже в НОВОМ контейнере после рестарта):
- читает sentinel `result` (exit-code хука, записан host-процессом);
- если `result` ещё нет и бюджет попыток не исчерпан → **defer** (повторный
finalizer-job с `available_at_delay_s`, как merge-gate defer); бюджет считается
из `jobs` (`LIKE '%deploy-finalize%'`, restart-safe);
- если `result` есть → **маппинг exit-code → deploy_status** (детерминированный,
unit-тестируемый): `0 → SUCCESS`, `1|2|иное → FAILED`; записать
`14-deploy-log.md` (frontmatter `deploy_status:`), смержить в `main` (паттерн
лога), затем вызвать `advance_stage(current_stage="deploy", finished_agent="deployer")`;
- далее срабатывают СУЩЕСТВУЮЩИЕ контракты: `SUCCESS` → terminal-sync `deploy → done`
+ release merge-lease; `FAILED` → откат БАГ-8 `deploy → development` +
`set_issue_blocked` + Plane/Telegram (BR-3, AC-4). `_parse_deploy_status` НЕ меняется.
### Механизм detached-запуска: ssh + setsid
Выбор: **ssh на хост (`slin@DEPLOY_SSH_HOST`) с setsid-detached исполнением** хука.
Обоснование: ssh-ключи уже смонтированы (INFRA P-2), не-self репо уже деплоятся по
ssh (единый путь), хук живёт на хосте и под `slin` имеет полный доступ к docker вне
контейнера → переживает рестарт 8500 (BR-2). `setsid`/`nohup` + redirect отвязывает
удалённый процесс от ssh-канала, чтобы он пережил гибель ssh-клиента при рестарте
контейнера. Отвергнуто: вызов через docker.sock изнутри контейнера = ровно мина
«убей себя на середине вызова».
Эскиз (точная сборка — за разработчиком):
```
ssh -o StrictHostKeyChecking=no slin@$DEPLOY_SSH_HOST \
"setsid bash -c 'cd /home/slin/repos/orchestrator && \
SOURCE_IMAGE=orchestrator-orchestrator-staging \
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE= \
PREV_IMAGE_FILE=.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy; \
echo \$? > <result-sentinel>' >> <hook.log> 2>&1 </dev/null &"
```
ssh-команда возвращается сразу; remote-процесс detached. Запись sentinel `result`
делает **обёртка** (`echo $? > result`), а НЕ хук — контракт хука нетронут.
### Build-once (BR-6, AC-7)
Прод обязан подняться на ОБРАЗЕ, прошедшем staging (а не на пересборке). Решение:
расширить хук **опциональным** `SOURCE_IMAGE` (обратно совместимо: не задан →
текущее поведение). При заданном `SOURCE_IMAGE` хук ПЕРЕД `up -d --no-build`
делает `docker tag $SOURCE_IMAGE $TARGET_IMAGE`. Для прод-self:
`SOURCE_IMAGE=orchestrator-orchestrator-staging``TARGET_IMAGE=orchestrator-orchestrator`.
Это единственное допустимое изменение хука; exit-code-контракт и дефолтное
staging-поведение не меняются. `git pull` хука обновляет рабочее дерево хоста для
будущих сборок, но РАЗВЁРНУТЫЙ артефакт = перетегированный staging-образ.
### Restart-safe состояние: sentinel-файлы (без миграции БД)
По образцу merge-lease (`<repos_dir>/.merge-lease-<repo>.json`) состояние деплоя
хранится в файлах под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (вне git,
видны и хосту, и контейнеру через mount `/home/slin/repos ↔ /repos`):
- `approve-requested` — Фаза A выполнена;
- `initiated` — Фаза B запущена (idempotency-guard);
- `result` — exit-code хука (пишет host-обёртка).
Бюджет finalize-defer считается из `jobs` (restart-safe), новых таблиц/колонок НЕТ
(TRZ §4).
## Последствия
### Плюсы
- `deploy_status: SUCCESS` становится ДОКАЗАННЫМ (реальный health-ok хука), не
декларацией LLM (BR-1).
- Self-restart безопасен: рестарт 8500 делает внешний host-процесс; орк себя не
убивает (BR-2). Вердикт фиксирует НОВЫЙ контейнер после рестарта.
- Критический путь self-restart **детерминирован** (без LLM) — главный выигрыш по
безопасности self-hosting; зеркалит детерминизм merge-gate ORCH-43.
- Approve вписан в существующую status-only модель — restart-safe, аудируемо в Plane,
идемпотентно (маркер `initiated`).
- Гонка чтения гейта закрыта: гейт читает РЕАЛЬНЫЙ итог через finalizer-defer.
- Build-once гарантирует «что тестировали — то в проде».
- Нетронуты: `STAGE_TRANSITIONS`, реестр QG, `_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, контракт хука (exit-code).
### Минусы / ограничения
- Вводится **новый детерминированный job-handler** в очереди (reserved-agent
`deploy-finalizer`, не-LLM) — расширение dispatch воркера/лаунчера. Контейнированное,
но это новая под-компонента → задача помечается `arch:major-change`.
- Перехваты в `advance_stage` усложняют стадию `deploy` (три ветки по
`finished_agent`/маркерам). Требуется аккуратное покрытие тестами (TC-04…TC-09).
- Build-once зависит от того, что deploy-staging оставил валидный образ
`orchestrator-orchestrator-staging`; при rebase merge-gate возможен дрейф
образ↔main (см. 10-tech-risks R-3).
- Approve = смена статуса Plane на `Approved`; человек должен понимать, что на
стадии `deploy` `Approved` означает «деплой в прод» (документируется в deployer.md
и INFRA.md).
### Что обязан сделать developer
1. `src/config.py`: `deploy_require_manual_approve: bool = True` + прод-параметры
хука/ssh + `deploy_finalize_delay_s` / `deploy_finalize_max_attempts`.
2. `src/stage_engine.py`: перехваты Фазы A/B + ветка finalizer (Фаза C через
`advance_stage(..., finished_agent="deployer")`).
3. Очередь: reserved-agent `deploy-finalizer` (детерминированный handler:
read-result | defer | map+write+advance). Маппинг exit→status — отдельная
чистая функция (unit TC-01/02/03).
4. `scripts/orchestrator-deploy-hook.sh`: опциональный `SOURCE_IMAGE` retag
(обратно совместимо) + прод `PREV_IMAGE_FILE`.
5. Уведомления (Plane+Telegram) на initiate/success/rollback (BR-5).
6. Документация: `deployer.md`, `INFRA.md`, `DEPLOY_HOOK.md`, `CHANGELOG.md`.
7. Отладка — только на staging-цели хука; прод 8500 в разработке не трогать.
## Связанные решения
- Глобальный ADR: `docs/architecture/adr/adr-0007-executable-self-deploy.md`.
- ORCH-35 staging-gate (`adr-0003`), ORCH-43 merge-gate (`adr-0006`),
ORCH-21 auto-rollback, ORCH-34 хук, ORCH-40 run-as-host-uid (`adr-0005`).

View File

@@ -0,0 +1,48 @@
# Инфраструктурные требования — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
> Топология не меняется (та же mva154, те же два контейнера). Меняется ПРОЦЕДУРА
> прод-деплоя орка: из ручной → исполняемая через хост-хук с ручным approve.
## 1. Контейнеры / порты — без изменений
- prod `orchestrator` (8500), staging `orchestrator-staging` (8501) — как в INFRA.md.
- Образы (имена для build-once): prod `orchestrator-orchestrator`,
staging `orchestrator-orchestrator-staging`.
## 2. Хост-предусловия (Owner, в git не коммитятся)
- **HP-1.** ssh-доступ из контейнера на хост: `ssh slin@$DEPLOY_SSH_HOST` работает
под uid 1000 ключом из `~/.orchestrator-ssh` (INFRA P-2). Без него detached-запуск
Фазы B невозможен.
- **HP-2.** `<repos_dir>/.deploy-state-<repo>/` доступен на запись и хосту (host-обёртка
пишет `result`), и контейнеру (finalizer читает) — обеспечивается mount
`/home/slin/repos ↔ /repos` (как merge-lease).
- **HP-3.** `PREV_IMAGE_FILE` для прод — отдельный путь
(`.deploy-prev-image-prod`), чтобы не путать снапшоты prod/staging.
- **HP-4 (P-4 из INFRA).** Прод-рестарт self — только в окно тишины; общий инстанс
с enduro-trails. На старте — под ручным approve (флаг `true`).
## 3. Переменные окружения (карта; значения — на хосте, в git только дескрипторы)
| Переменная | Назначение | Дефолт |
|-----------|-----------|--------|
| `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` | ручной approve перед прод-деплоем | `true` |
| `DEPLOY_SSH_USER` / `DEPLOY_SSH_HOST` | ssh-цель хост-хука | — (INFRA-карта) |
| `DEPLOY_HOOK_SCRIPT` | путь к хуку на хосте | `scripts/orchestrator-deploy-hook.sh` |
| прод `TARGET_SERVICE/PORT/IMAGE`, `COMPOSE_PROFILE` | override прод-профиля хука | `orchestrator`/`8500`/`orchestrator-orchestrator`/пусто |
| `SOURCE_IMAGE` (новый параметр хука) | образ для build-once retag | пусто → текущее поведение |
| `ORCH_DEPLOY_FINALIZE_DELAY_S` | задержка перед первым finalize-поллом | > 60с (health-loop хука) |
| `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS` | бюджет finalize-defer | bounded (anti-livelock) |
Прописать дескрипторы в `.env.example` / INFRA.md. Реальные значения не коммитить.
## 4. Сетевые / процессные требования
- Detached host-процесс (ssh + setsid) обязан пережить рестарт прод-контейнера 8500.
- Finalizer-job исполняется в НОВОМ контейнере после рестарта (очередь restart-safe).
- MTTR авто-rollback < 60с (health-loop хука 10×6с уже укладывается, BR-8/AC-9).
## 5. Что НЕ требуется
- Новых контейнеров/портов/сервисов — нет.
- Изменений `docker-compose.yml` — не требуется (build-once через retag, не профиль).
- Multi-node / облако / message-queue — нет (принципы проекта).

View File

@@ -0,0 +1,34 @@
# Требования к данным / схеме БД — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
## Решение: миграция БД НЕ требуется
Схема SQLite (`events`, `tasks`, `agent_runs`, `jobs`) не меняется. Обоснование:
1. **Вердикт деплоя** — в `14-deploy-log.md` (frontmatter `deploy_status:`), как
сейчас. `_parse_deploy_status` не трогаем (AC-10).
2. **Approve / initiated / result-состояние** — restart-safe через **sentinel-файлы**
под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (паттерн merge-lease
`<repos_dir>/.merge-lease-<repo>.json`), а не через новую таблицу/колонку:
- `approve-requested` — Фаза A;
- `initiated` — Фаза B (idempotency-guard);
- `result` — exit-code хука (пишет host-обёртка).
3. **Бюджет finalize-defer** считается из существующей таблицы `jobs`
(`task_content LIKE '%deploy-finalize%'`), как `_merge_defer_count` для merge-gate
— restart-safe, без новых полей.
4. **Finalizer-job** использует существующую структуру `jobs` (agent, repo,
task_content, task_id, available_at). Reserved-agent `deploy-finalizer` — это
значение в колонке `agent`, схема не меняется.
## Почему файлы, а не БД
- Sentinel должен быть виден И хосту (пишет `result`), И контейнеру (читает finalizer);
файл на общем mount это обеспечивает, SQLite-запись из host-обёртки — нет.
- Зеркалит уже принятый паттерн merge-lease (ORCH-43) — единообразие, restart-safe,
crash-реклейм по возрасту файла.
Если разработчик при реализации сочтёт необходимым поле статуса approve в БД —
это требует обновления данного ADR с обоснованием; по умолчанию — без миграции
(согласовано с TRZ §4).

View File

@@ -0,0 +1,23 @@
# Технические риски — ORCH-036
Work Item: ORCH-036
Stage: architecture
Автор: architect
| ID | Риск | Влияние | Вероятность | Митигация |
|----|------|---------|-------------|-----------|
| R-1 | Detached host-процесс не пережил рестарт 8500 (ssh-канал убит вместе с контейнером) | Деплой не завершён, `result` не записан, finalizer вечно defer'ит | Средняя | `setsid`/`nohup` + redirect отвязывает remote-процесс от ssh; интеграционная проверка на staging-цели (TC-08); finalize-defer bounded → по исчерпании `set_issue_blocked` + Telegram |
| R-2 | Преждевременное чтение `check_deploy_status` (вердикта ещё нет) | Ложный FAILED → ложный откат на development | Средняя | Фаза B возвращается БЕЗ advance; гейт запускает только finalizer (Фаза C) после появления `result`; defer пока `result` отсутствует |
| R-3 | Дрейф образ↔main: merge-gate сделал rebase, но staging-образ собран до rebase → build-once тегирует «не тот» код | В прод уезжает не точно то, что в `main` | Низкая | merge-gate (ORCH-43) делает re-test после rebase; build-once = «что валидировано на staging», что и есть контракт; задокументировано как осознанное ограничение; усиление (rebuild+revalidate staging после rebase) — отдельная задача |
| R-4 | Двойной Approved (человек кликнул дважды / дубль webhook) запускает деплой дважды | Двойной рестарт прода, гонка | Средняя | Маркер `initiated` (idempotency-guard); event-dedup webhook'ов Plane уже есть |
| R-5 | exit 2 хука (rollback тоже упал) → 8500 лежит → finalizer/новый контейнер не поднялся | Конвейер всех проектов встал | Низкая | health-loop + авто-rollback хука минимизируют; `restart: unless-stopped` поднимет контейнер на ПРЕДЫДУЩЕМ образе если retag не случился; exit 2 → `deploy_status: FAILED` + откат + Telegram-алерт; ручной `--rollback` хука как backstop |
| R-6 | Reserved-agent `deploy-finalizer` ошибочно уйдёт в LLM-путь лаунчера (`_spawn` → ValueError) | Finalizer не отработает | Низкая | Перехват ДО `_spawn` в `launch_job`; unit-тест маршрутизации |
| R-7 | sentinel-файлы не видны контейнеру/хосту (mount/uid) | Фазы B/C не синхронизируются | Низкая | Тот же mount и uid-модель, что у merge-lease (ORCH-40/43); HP-2 в 07-infra |
| R-8 | Approve через смену статуса Plane конфликтует с auto-advance других стадий | Случайный `Approved` на `deploy` ничего не ломает, но семантика неочевидна | Низкая | Перехват по `current_stage=="deploy"` + `finished_agent is None` + маркеры; задокументировать в deployer.md/INFRA, что `Approved` на `deploy` = «деплой в прод» |
| R-9 | Самодеплой ORCH ломает прод во время разработки самой ORCH-36 | Групповой простой (enduro-trails) | Низкая | Вся отладка — на staging-цели хука (8501); прод 8500 не трогать (AC: DoD); флаг approve=true |
## Сводный приоритет
- **Блокеры дизайна:** R-1, R-2 — закрыты архитектурой (setsid-detached + finalizer-defer).
- **Безопасность self-hosting:** R-5, R-9 — закрыты обязательным approve + staging-отладкой
+ авто-rollback + `restart: unless-stopped`.
- **Корректность:** R-3, R-4 — осознанные ограничения / idempotency-guard.

View File

@@ -0,0 +1,64 @@
---
type: review
work_item_id: ORCH-036
verdict: APPROVED
version: 2
---
# Review ORCH-036 — Исполняемый самодеплой стадии `deploy` (Вариант B)
## Summary
Re-review после фикса двух P1 из версии 1. Оба блокера устранены:
1. **Stale deploy-state маркеры** — добавлен `self_deploy.clear_state(repo, work_item_id)`
(never-raise, idempotent, рекурсивное удаление `<repos_dir>/.deploy-state-<repo>/<wi>/`)
в ветке БАГ-8-отката `check_deploy_status` FAILED (`_handle_qg_failure_rollbacks`,
`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`)
как belt-and-suspenders. Добавлен регрессионный тест
`tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`,
доказывающий, что после FAILED → откат → фикс → повторный заход на `deploy` Фаза B
РЕАЛЬНО инициирует деплой (нет no-op по устаревшему `initiated`), плюс
`tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`.
2. **`.env.example`** — добавлен полный блок дескрипторов `ORCH_SELF_DEPLOY_*` /
`ORCH_DEPLOY_*` (14 настроек, плейсхолдеры, секреты не коммитятся) по образцу
merge-gate ORCH-043, с подробными комментариями.
Реализация трёхфазного исполняемого самодеплоя соответствует ADR-001 и закрывает
критерии приёмки AC-1…AC-13. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` /
`_parse_deploy_status` / БАГ-8 / terminal-sync / merge-gate (ORCH-43) НЕ тронуты;
условность по репо (`self_deploy_applies`) корректна; перехваты упорядочены верно
(Phase B после terminal-check, Phase A после merge-gate); `deploy-finalizer`
детерминированный no-LLM reserved-agent, перехвачен в launcher до `_spawn`. Все
импорты (`set_issue_in_review`, `plane_add_comment`, `set_issue_blocked`,
`send_telegram`) присутствуют. `pytest tests/`**568 passed**.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет — оба P1 из версии 1 устранены и покрыты тестами)
### P2 — Should fix
- (нет блокирующих; прежний P2 про сквозную процедуру оператора частично закрыт:
env-карта новых настроек добавлена в INFRA.md, пошаговый approve→deploy описан в
deployer.md и DEPLOY_HOOK.md)
## Документация
Обновлена содержательно и в том же PR:
- `.openclaw/agents/deployer.md` — стадия `deploy` переписана: self-hosting путь
(Фазы A/B/C, явный запрет рестарта 8500 изнутри агента) vs прежний синхронный
ssh-путь для не-self репо;
- `docs/operations/INFRA.md` — env-карта всех новых `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*`;
- `docs/operations/DEPLOY_HOOK.md``SOURCE_IMAGE` build-once + прод-пример;
- `docs/architecture/README.md` — раздел «Исполняемый самодеплой стадии `deploy`»;
- `CHANGELOG.md` — запись Added (фича) + запись Fixed (review-fix: clear_state + .env.example);
- ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md` + глобальный
`docs/architecture/adr/adr-0007-executable-self-deploy.md`;
- **`.env.example`** — канонический шаблон (CLAUDE.md №8, ТЗ §2.6) дополнен (был пробел в v1).
Документация = golden source: изменения `src/` сопровождены синхронным обновлением
доки в том же PR. Ось документации — PASS.

View File

@@ -0,0 +1,90 @@
---
type: test-report
work_item_id: ORCH-036
result: PASS
---
# Test Report — ORCH-036
Исполняемый самодеплой стадии `deploy` (Вариант B) — дёргает хост-хук
`scripts/orchestrator-deploy-hook.sh`, три фазы (A/B/C), условность по self-hosting репо.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (pluggy 1.6.0, anyio 4.13.0, asyncio 0.23.8 — mode AUTO)
- Worktree: `feature/ORCH-036-orch-36-deploy-b`
- Дата: 2026-06-06
- Prod (8500) во время тестов НЕ тронут: вся проверка изолированная (моки subprocess/ssh/хука).
Smoke выполнялся read-only GET-запросами.
## Smoke test API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| GET /health | `{"status":"ok","service":"orchestrator"}` — OK |
| GET /status | OK (отдаёт активные задачи) |
| GET /queue | OK (counts/max_concurrency/resilience; breaker=closed, preflight_ok=true) |
`curl` в окружении отсутствует — smoke выполнен через `urllib.request` (эквивалент GET).
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | exit 0 → deploy_status: SUCCESS | test_tc01_exit0_maps_to_success | PASS |
| TC-02 | exit 1 (rolled back) → FAILED | test_tc02_exit1_rolled_back_maps_to_failed | PASS |
| TC-03 | exit 2 (rollback тоже упал) → FAILED | test_tc03_exit2_rollback_also_failed_maps_to_failed | PASS |
| TC-04 | DEPLOY_REQUIRE_MANUAL_APPROVE дефолт == true | test_tc04_manual_approve_default_true | PASS |
| TC-05 | true и нет approve → прод-хук НЕ вызван | test_tc05_no_approve_does_not_call_prod_hook | PASS |
| TC-06 | true и approve → прод-хук вызван ровно 1 раз | test_tc06_approved_calls_prod_hook_exactly_once | PASS |
| TC-07 | is_self_hosting_repo: только orchestrator True | test_tc07_is_self_hosting_repo_only_orchestrator | PASS |
| TC-08 | self-репо: рестарт detached host-процессом | test_tc08_self_repo_launches_detached_host_process | PASS |
| TC-09 | не-self репо: прежний ssh-путь | test_tc09_non_self_repo_uses_legacy_path | PASS |
| TC-10 | FAILED → откат deploy→development, blocked, release lease | test_tc10_failed_deploy_rolls_back_to_development | PASS |
| TC-11 | staging_status FAILED → до deploy не доходит | test_tc11_staging_failed_never_reaches_deploy | PASS |
| TC-12 | успех → Plane-коммент + Telegram | test_tc12_success_notifies_plane_and_telegram | PASS |
| TC-13 | откат → Plane-коммент + Telegram | test_tc13_rollback_notifies_plane_and_telegram | PASS |
| TC-14 | build-once: retag staging-образа, без build | test_tc14_deploy_command_retags_staging_image_no_build | PASS |
| TC-15 | _parse_deploy_status контракт цел (проза не проходит) | test_qg_checks::test_tc15_* (5 кейсов) | PASS |
| TC-16 | STAGE_TRANSITIONS deploy/deploy-staging не изменены | test_stages::test_tc16_* | PASS |
| TC-17 | terminal-sync deploy→done сохранён | test_tc17_success_deploy_syncs_terminal_done | PASS |
| TC-18 | merge-gate (ORCH-43) на ребре не затронут | test_merge_gate (14 кейсов) | PASS |
| TC-19 | симуляция битого деплоя: авто-rollback → healthy, exit 1 | test_tc19_unhealthy_deploy_auto_rolls_back_exit1 | PASS |
Доп. регрессионные тесты (review-fix): `test_clear_state_removes_all_markers_and_is_idempotent`,
`test_tc11_re_deploy_after_rollback_not_wedged`оба PASS (stale deploy-state очищается, повторный
заход на deploy после отката не зависает).
## Покрытие критериев приёмки
| AC | Покрыт тестами | Статус |
|----|----------------|--------|
| AC-1 реальный деплой (не бумажный) | TC-01..03, TC-14, TC-19 | PASS |
| AC-2 self-репо рестарт detached, агент себя не убивает | TC-08 | PASS |
| AC-3 deploy_status из exit-code | TC-01..03 | PASS |
| AC-4 FAILED → откат на development | TC-10 | PASS |
| AC-5 ручной approve реально тормозит прод | TC-05, TC-06 | PASS |
| AC-6 уведомления о промоуте и откате | TC-12, TC-13 | PASS |
| AC-7 build-once (образ из staging) | TC-14 | PASS |
| AC-8 staging-гейт обязателен | TC-11 | PASS |
| AC-9 авто-rollback восстанавливает прод (MTTR<60с) | TC-19 | PASS |
| AC-10 инварианты не сломаны | TC-15..18 + полный регресс | PASS |
| AC-11 условность по репо (не-self не ломается) | TC-07, TC-09 | PASS |
| AC-12 флаг авто НЕ выключен (остаётся true) | TC-04 | PASS |
| AC-13 документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS |
## Вывод pytest
Полный регресс:
```
======================= 568 passed, 1 warning in 15.25s ========================
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, не связан с задачей)
Целевые модули тест-плана:
```
======================== 46 passed, 1 warning in 2.17s =========================
```
## Итог
**PASS** — все 19 TC зелёные, все критерии приёмки AC-1…AC-13 покрыты, полный регресс
568/568 passed, smoke API OK, прод (8500) не тронут. Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,39 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T21:47:48Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
Executed canonically inside the container (ORCH-048, ADR-001):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
(The agent container has no `docker` CLI; the canonical `docker exec` was invoked via the
Docker Engine API over the mounted `/var/run/docker.sock`, which is equivalent — the command
ran inside `orchestrator-staging` so the B6 registry-isolation check read the staging
process-env `.env.staging`.)
**Result: 10/10 checks PASS — exit code 0.**
| Block | Check | Verdict |
|-------|-------|---------|
| A SMOKE | A1 `GET /health` → 200 status=ok | PASS |
| A SMOKE | A2 `GET /queue` → 200 (counts/max_concurrency/resilience) | PASS |
| A SMOKE | A3 `ORCH_STAGING=true` (not prod) | PASS |
| B ACCESS | B4 Plane sandbox project accessible | PASS |
| B ACCESS | B5 Gitea `orchestrator-sandbox` accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via `/webhook/plane` | PASS |
| C E2E | C9a Branch appears in `orchestrator-sandbox` | PASS |
| C E2E | C9b Analyst job enqueued in staging queue | PASS |
CLEANUP: test branch deleted, Plane SANDBOX issue deleted, staging DB job/task rows removed
(`try/finally` guaranteed). No prod (8500) container was touched.

View File

@@ -0,0 +1,74 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T15:10:00+00:00
target: prod orchestrator (8500) + staging orchestrator-staging (8501)
mode: artifact-validated; prod restart handed off to Owner (self-hosting safeguard)
---
# Deploy Log — ORCH-040
## Verdict
`deploy_status: SUCCESS` — deployable artifact validated and ready. The automated
deploy-stage responsibility is complete. **The actual prod-container restart is an
Owner action** (see Handoff) and was deliberately NOT performed by this agent.
## Why no in-task prod restart
ORCH-040 is a **self-hosting** change: it makes the running prod instance
`orchestrator` (8500) run as `user: "1000:1000"` instead of root. Per CLAUDE.md
rule #1 and INFRA.md §Self-hosting, an ORCH task **must not** restart or drop the
prod container — the single prod instance with a shared DB/queue also serves
enduro-trails, so a restart inside the task is a group risk for all projects.
Real prod deploys go through `scripts/orchestrator-deploy-hook.sh` (DEPLOY_HOOK.md)
executed by the Owner, not by the deployer agent.
## What was validated
- **Staging gate green** — `15-staging-log.md``staging_status: SUCCESS`,
10/10 checks PASS on the live staging instance (8501), already running under
`user: "1000:1000"`. Artifacts created as `slin:slin`, agent `exit_code=0`,
docker.sock + ssh-deploy paths live. This is the canonical pre-prod safeguard
(ADR-0003 staging gate, ADR-001 §Порядок безопасного внедрения step 1).
- **Deployable artifact correct** — `docker-compose.yml` on branch
`feature/ORCH-040-root-git` (commit `f81715b`):
- both services have `user: "1000:1000"`;
- `group_add: ["999"]` **present** for both (МИНА 1 — docker.sock access via gid
999, not root — NOT removed);
- SSH mount retargeted `/root/.ssh``/home/slin/.ssh` to match the launcher's
forced `HOME=/home/slin`;
- claude mounts unchanged.
- `src/agents/launcher.py` and `Dockerfile` unchanged, as the ADR mandates.
## Handoff — Owner prod cut-over (out-of-code, ADR-001 §Host-prerequisites & §Порядок)
Perform in this order, **only in a quiet window** (P-4):
1. **P-1 (BLOCKER)**`chown -R 1000:1000 /home/slin/.claude`; verify
`sudo -u '#1000' test -r /home/slin/.claude/.credentials.json`. Without this,
preflight (ORCH-044) will fail the whole pipeline.
2. **P-2** — ssh keys in `/home/slin/.orchestrator-ssh` readable by uid 1000.
3. **P-3** — confirm `id slin``1000:1000`; `/repos`, `/app/data` already `1000:1000`.
4. **P-4** — confirm `GET http://localhost:8500/status` shows **no active tasks**
before restarting prod (shared instance with enduro-trails).
5. Prod cut-over via the deploy hook (conscious prod override):
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
The hook captures the previous image, runs a 60s health loop, and auto-rolls
back on failure.
6. Post-deploy regression: new tracked artifacts are `slin:slin`; `git pull`
under slin works without manual `chown`.
## Summary
| Item | State |
|------|-------|
| Staging gate (`check_staging_status`) | SUCCESS (10/10) |
| Compose artifact (user/group_add/ssh) | correct, МИНА 1 intact |
| In-task prod restart | NOT performed (self-hosting safeguard, by design) |
| Prod cut-over | handed off to Owner (P-1…P-4 + deploy hook) |
| Deploy stage verdict | SUCCESS |

View File

@@ -0,0 +1,7 @@
# Business Request: Безопасная параллель в одном репо: merge-gate + auto-rebase + re-test
Work Item ID: ORCH-043
## Description
TBD

View File

@@ -0,0 +1,114 @@
# 01 — Business Requirements Document (BRD)
**Work Item:** ORCH-043
**Тема:** Безопасная параллель в одном репо: merge-gate + auto-rebase + re-test
**Проект:** orchestrator (self-hosting)
**Автор:** Analyst
**Дата:** 2026-06-06
---
## 1. Контекст и проблема
Оркестратор ведёт несколько work item **параллельно**, каждый в своём изолированном
git worktree / ветке (`feature/ORCH-NNN-slug`, ORCH-2/S-4). Все ветки одного проекта
исходят из общего `origin/main` и в конце конвейера **вливаются обратно в `main`**.
Текущий конвейер валидирует ветку **относительно того состояния `main`, из которого
она была создана**, а не относительно `main` на момент слияния:
- `check_ci_green` (стадия `development`) — CI зелёный **на ветке** (Gitea commit status ветки).
- `check_tests_passed` (стадия `testing`) — вердикт тестировщика по коду **ветки**.
- На стадии `deploy` ветка вливается в `main` (слияние выполняет deployer-агент,
см. `src/webhooks/gitea.py` — комментарий про «deployer merges the PR at the START of its run»).
**Между «ветка проверена» и «ветка влита» `main` мог уйти вперёд** из-за слияния другой
параллельной задачи. Возникает **семантический (логический) конфликт слияния**: git
сливает ветки без текстового конфликта, но объединённый код `main` сломан — тесты,
которые были зелёными на ветке, на обновлённом `main` падают.
### Почему это критично именно здесь (self-hosting)
Проект ORCH правит инструмент, который СЕЙЧАС работает в проде и обслуживает другие
проекты (enduro-trails) из одного инстанса с общей БД и общей очередью (см. `CLAUDE.md`,
`docs/operations/INFRA.md`). Сломанный `main` оркестратора = встал конвейер ВСЕХ проектов.
Две параллельные ORCH-задачи, каждая «зелёная» по отдельности, при последовательном
слиянии способны положить прод.
### Сценарий-иллюстрация
1. Задачи A и B ответвлены от `main@C0`.
2. A проходит конвейер, вливается → `main@C1`.
3. B тестировалась против `C0`; её CI зелёный относительно `C0`. Git-слияние B в `C1`
проходит без текстового конфликта, но `C1` содержит изменения A, ломающие B.
4. `main` становится красным. Конвейер всех проектов деградирует.
---
## 2. Цель
Гарантировать, что ветка вливается в `main` **только если она проверена против
актуального `origin/main`**. Перед слиянием ветка автоматически догоняет `main`
(auto-rebase) и **повторно тестируется** (re-test); зелёный результат на актуальном
`main` — обязательное условие слияния (merge-gate). Слияния в `main` одного репозитория
**сериализуются**, чтобы окно гонки не воспроизводилось между двумя гейтами.
## 3. Заинтересованные стороны
- **Owner / разработчики** — не хотят красный `main` и ручные разборы конфликтов.
- **Все проекты на инстансе** — зависят от живого прод-оркестратора.
- **Агенты конвейера** — получают детерминированный гейт вместо ручной координации.
## 4. Объём (Scope)
### В объёме
1. **Merge-gate** — детерминированный гейт перед слиянием в `main`: пропускает
слияние только если ветка не отстаёт от `origin/main` И повторная проверка зелёная.
2. **Auto-rebase** — если ветка отстаёт от `origin/main`, автоматически догнать `main`
(rebase/merge ветки на актуальный `origin/main`) в worktree и запушить результат.
3. **Re-test** — после auto-rebase повторно прогнать тест-набор на догнанной ветке;
зелёный результат — условие прохода гейта.
4. **Сериализация слияний** — в пределах одного репозитория одновременно «догон+слияние»
выполняет только одна задача (merge-lock), иначе гонка воспроизводится.
5. **Откаты при неуспехе** — текстовый конфликт rebase ИЛИ красный re-test → возврат
задачи на `development` (по образцу существующих откатов) с понятным комментарием.
6. **Конфигурируемость** — пороги/тайм-ауты re-test и поведение гейта вынесены в `settings`.
### Вне объёма
- Изменение логики стадий `analysis` / `architecture` / `review`.
- Замена самого механизма слияния PR в Gitea (UI/настройки репозитория).
- Реальные прод-деплои (остаются за `scripts/orchestrator-deploy-hook.sh`).
- Кросс-репозиторная сериализация (гейт защищает `main` каждого репо отдельно).
## 5. Бизнес-требования (BR)
| ID | Требование |
|----|------------|
| BR-1 | Перед слиянием ветки в `main` оркестратор обязан проверить, что ветка содержит последний `origin/main` (не отстаёт). |
| BR-2 | Если ветка отстаёт — оркестратор автоматически догоняет её до `origin/main` без участия человека (auto-rebase). |
| BR-3 | После догона тест-набор повторно прогоняется; слияние разрешено только при зелёном результате (re-test). |
| BR-4 | Текстовый конфликт при auto-rebase или красный re-test НЕ приводит к слиянию: задача откатывается на `development` для ручного фикса. |
| BR-5 | В пределах одного репозитория «догон+проверка+слияние» сериализуются: две задачи не могут одновременно пройти merge-gate и влиться. |
| BR-6 | Гейт детерминированный (Python/гит-команды + код тестов), а не доверие LLM-агенту. |
| BR-7 | Гейт обязателен минимум для self-hosting репозитория `orchestrator`; применим к любому репо с параллельными задачами. |
| BR-8 | Все события гейта (догон, re-test, проход/откат) логируются и отражаются комментарием в Plane, без рассинхрона стадий. |
## 6. Критерии успеха
- Воспроизводимый ранее сценарий «две зелёные ветки ломают `main`» более не приводит
к красному `main`: вторая ветка либо догоняется и проходит re-test, либо откатывается.
- Прод-контейнер `orchestrator` не перезапускается и не падает в рамках задачи.
- Реестр гейтов и стадий остаётся консистентным (snapshot-тесты обновлены осознанно).
## 7. Риски и ограничения
- **Гонка между двумя гейтами** — снимается merge-lock (BR-5); без него фикс неполон.
- **Долгий re-test** — нужен тайм-аут и понятный откат, а не вис задачи.
- **Force-push догнанной ветки** — допустим только `--force-with-lease` и только по
own-ветке задачи; никогда по `main`.
- **Self-hosting** — любые изменения не должны ронять/рестартить прод-оркестратор;
обязательная страховка стадией `deploy-staging` (порт 8501) сохраняется.
- Окончательное место встройки в конвейер (новая стадия / гейт существующего перехода /
шаг перед слиянием) — **решение архитектора** (ADR), BRD фиксирует требуемое поведение.
## 8. Связанные артефакты
- `02-trz.md` — техническое задание (модули, гейт, конфиг, точки встройки).
- `03-acceptance-criteria.md` — критерии приёмки PASS/FAIL.
- `04-test-plan.yaml` — план тестов.
- Контекст кода: `src/qg/checks.py`, `src/stage_engine.py`, `src/git_worktree.py`,
`src/agents/launcher.py`, `src/webhooks/gitea.py`, `src/stages.py`, `src/config.py`.

View File

@@ -0,0 +1,161 @@
# 02 — Техническое задание (ТЗ)
**Work Item:** ORCH-043
**Тема:** merge-gate + auto-rebase + re-test (безопасная параллель в одном репо)
**Автор:** Analyst
> ТЗ описывает ТРЕБУЕМОЕ поведение и конкретные точки изменения кода. Окончательный
> выбор места встройки в конвейер (новая стадия vs гейт существующего перехода vs шаг
> перед слиянием) и детали reconciliation — **за архитектором** (ADR в `06-adr/`).
> Если ТЗ окажется нереализуемым — вернуть на стадию `analysis`, не комментировать задним числом.
---
## 1. Задействованные модули `src/`
| Модуль | Роль в изменении |
|--------|------------------|
| `src/merge_gate.py` (**новый**) | Ядро фичи: ancestor-check, auto-rebase, re-test, merge-lock. Чистые функции + git-операции в worktree. |
| `src/qg/checks.py` | Новый QG-check `check_branch_mergeable` (merge-gate) + регистрация в `QG_CHECKS`. Переиспользует паттерн `check_tests_local` (pytest в worktree) и `_repo_path`. |
| `src/stages.py` | Встройка merge-gate в `STAGE_TRANSITIONS` (точное место — за архитектором; см. §6). |
| `src/stage_engine.py` | Ветка отката merge-gate → `development` в `_handle_qg_failure_rollbacks` + диспетчеризация нового check в `_run_qg`. |
| `src/git_worktree.py` | Возможные хелперы: проверка «behind origin/main», rebase, push `--force-with-lease`. Не ломать сигнатуры `ensure_worktree` / `get_worktree_path`. |
| `src/config.py` | Новые `settings`: тайм-аут re-test, вкл/выкл гейта, политика отстающей ветки, тайм-аут lock. |
| `src/agents/launcher.py` | Если merge-gate встраивается как шаг перед слиянием на стадии `deploy` — точка, где deployer запускается, может потребовать координации с lock (за архитектором). |
| `tests/` | Новые тесты (см. `04-test-plan.yaml`) + обновление snapshot-тестов реестра/стадий. |
## 2. Функциональные требования к `src/merge_gate.py`
Предлагаемый публичный контракт (имена финализирует архитектор; поведение обязательно):
### 2.1 `branch_is_behind_main(repo, branch) -> bool`
- `git fetch origin main` в main-clone/worktree (best-effort, never-raise → трактуем
как «не удалось определить» и НЕ пропускаем слияние вслепую).
- Ветка считается отстающей, если `origin/main` **не** является предком HEAD ветки
(`git merge-base --is-ancestor origin/main <branch>` → ненулевой код).
### 2.2 `auto_rebase_onto_main(repo, branch) -> (ok: bool, reason: str)`
- Выполняется в изолированном worktree ветки (`ensure_worktree`), НЕ в общем clone.
- Догнать ветку до `origin/main` (rebase либо merge — выбор архитектора; критично:
результат содержит весь `origin/main` и историю/изменения ветки).
- **Текстовый конфликт** → отменить операцию (`git rebase --abort` / `git merge --abort`),
worktree оставить чистым, вернуть `(False, "rebase conflict: <файлы>")`.
- **Чистый догон** → `git push --force-with-lease origin <branch>` (ТОЛЬКО ветка задачи,
НИКОГДА `main`). Вернуть `(True, ...)`.
- Контракт never-raise: любая git/OS-ошибка → `(False, "<reason>")`, не исключение.
### 2.3 `retest_branch(repo, branch) -> (ok: bool, reason: str)`
- Прогнать тест-набор проекта в worktree догнанной ветки. Канон — как в
`check_tests_local`: `python -m pytest` (точная команда/каталог — за архитектором,
согласованно с CI-конфигом `.gitea/workflows/`).
- Тайм-аут `settings.merge_retest_timeout_s`; превышение → `(False, "re-test timeout")`.
- Возврат: `(True, "re-test green")` при коде 0, иначе `(False, "re-test failed: <tail>")`.
### 2.4 Merge-lock (сериализация, BR-5)
- Реализовать межзадачную сериализацию «догон+re-test+слияние» в пределах одного `repo`.
- Допустимые реализации (выбор архитектора): файловый lock в `repos_dir`, advisory-lock,
либо строка-замок в SQLite. Требования: restart-safe, с тайм-аутом
`settings.merge_lock_timeout_s`, корректное освобождение при ошибке/падении.
- Под локом: повторно сверить «не отстаёт» ПОСЛЕ захвата (double-check), т.к. `main`
мог уйти, пока ждали lock.
## 3. Новый QG-check (`src/qg/checks.py`)
```
check_branch_mergeable(repo, work_item_id, branch) -> tuple[bool, str]
```
Поведение (детерминированно, без участия LLM):
1. Захватить merge-lock для `repo` (с тайм-аутом). Не удалось → `(False, "merge-lock busy")`.
2. Если ветка не отстаёт от `origin/main``(True, "branch up-to-date with main")`.
3. Иначе `auto_rebase_onto_main`:
- конфликт → `(False, "rebase conflict: ...")`;
- успех → `retest_branch`:
- зелёный → `(True, "rebased onto main, re-test green")`;
- красный/тайм-аут → `(False, "re-test failed after rebase: ...")`.
4. Освободить lock в `finally`.
- Зарегистрировать в `QG_CHECKS` под ключом `"check_branch_mergeable"`.
- Контракт never-raise (как у соседних чеков): исключение → `(False, "<reason>")`.
> **Опционально (за архитектором):** флаг `settings.merge_gate_enabled`; при `False`
> чек возвращает `(True, "merge-gate disabled")` (безопасный no-op для постепенного
> раскатывания, по образцу условного staging-гейта ORCH-35).
## 4. Изменения схемы БД
- **Не требуется** для базовой реализации (lock через файл/advisory).
- ЕСЛИ архитектор выберет lock через SQLite — добавить таблицу/строку-замок миграцией,
совместимой с текущей инициализацией `src/db.py` (никаких ломающих изменений `tasks`,
`agent_runs`, `jobs`, `events`). Это решение фиксируется в ADR.
## 5. Изменения API
- Новых HTTP-эндпоинтов **не требуется**.
- Допустимо (не обязательно) расширить `GET /status` или `GET /queue` индикатором
«merge-gate: rebasing/re-testing/locked» для наблюдаемости — на усмотрение архитектора,
без изменения существующих контрактов ответов.
## 6. Точки встройки в конвейер (требование + кандидаты)
**Требование:** merge-gate отрабатывает как можно ближе к фактическому слиянию в `main`
и ДО него. Слияние ветки в `main` НЕ должно происходить в обход гейта.
Кандидаты (окончательно — ADR архитектора):
- **(A)** Гейт на переходе `deploy-staging → deploy` или новый под-гейт перед слиянием:
deployer вливает PR на стадии `deploy`, поэтому проверка «догнать+re-test» логично
встаёт непосредственно перед запуском deployer.
- **(B)** Новая стадия `merge-gate` между `deploy-staging` и `deploy` с агентом=None и
`qg="check_branch_mergeable"`.
- **(C)** Перенести само слияние в `main` из ответственности deployer-агента в
детерминированный шаг оркестратора, защищённый merge-gate (более крупное изменение).
При любом варианте, меняющем `STAGE_TRANSITIONS` или `QG_CHECKS`:
- обновить `docs/architecture/README.md` (таблица стадий + реестр QG, §«Конвейер»);
- обновить snapshot-тесты `tests/test_qg_registry_snapshot.py`
(`_EXPECTED_QGS`, `_EXPECTED_TRANSITIONS`) — осознанно, в этом же PR;
- сохранить порядок ключей `STAGE_TRANSITIONS` (от него зависит `get_previous_stage`).
## 7. Откаты (интеграция со `stage_engine`)
В `_handle_qg_failure_rollbacks` добавить ветку для merge-gate FAIL по образцу
`check_staging_status` / `check_deploy_status`:
- `update_task_stage(task_id, "development")`, `set_issue_blocked(work_item_id)`;
- комментарий в Plane (`plane_add_comment`, author="deployer" или системный) с причиной
(конфликт rebase / красный re-test) — дословный `reason` гейта;
- Telegram-алерт (`send_telegram`);
- учитывать `MAX_DEVELOPER_RETRIES`, не плодить бесконечные заворот-циклы.
- В `_run_qg` добавить диспетчеризацию `check_branch_mergeable` с сигнатурой
`(repo, work_item_id, branch)` (как у артефактных чеков).
## 8. Изменения конфигурации (`src/config.py`, env-префикс `ORCH_`)
| Setting | Назначение | Дефолт (предложение) |
|---------|-----------|----------------------|
| `merge_gate_enabled: bool` | Глобальный вкл/выкл гейта | `True` |
| `merge_retest_timeout_s: int` | Тайм-аут повторного прогона тестов | `600` |
| `merge_lock_timeout_s: int` | Тайм-аут ожидания merge-lock | `300` |
| `merge_gate_repos: str` | (опц.) ограничить гейт списком репо; пусто = все | `""` |
Значения и имена финализирует архитектор; задокументировать в `.env.example` и
`docs/architecture/README.md`.
## 9. Требования к наблюдаемости / документации (golden source)
- Обновить `docs/architecture/README.md`: описание merge-gate, auto-rebase, re-test,
merge-lock; при изменении стадий/реестра — соответствующие таблицы.
- Обновить `CHANGELOG.md`.
- Завести ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` (механизм догона,
выбор rebase vs merge, реализация lock, место встройки).
- Все ветки кода — с лог-сообщениями (`logger.info/warning/error`) по образцу соседних
гейтов, чтобы поведение читалось в `/app/data/runs` и логах сервиса.
## 10. Нефункциональные требования
- **Безопасность self-hosting:** никогда не push в `main`; force только `--force-with-lease`
по ветке задачи; прод-контейнер `orchestrator` не рестартить/не ронять.
- **Изоляция:** все git-операции — в worktree ветки (`ensure_worktree`), не в общем clone,
чтобы не словить S-4-гонку параллельных задач.
- **Идемпотентность/restart-safe:** lock и гейт корректно ведут себя при рестарте сервиса.
- **Never-raise** контракт у всех новых чеков/парсеров (как в текущем `src/qg/checks.py`).
- **Совместимость:** не менять сигнатуры/поведение существующих QG-чеков и вебхуков.
## 11. Артефакты pipeline, которые должны быть созданы/обновлены
- `src/merge_gate.py` (новый), изменения в `src/qg/checks.py`, `src/stages.py`,
`src/stage_engine.py`, `src/config.py`, при необходимости `src/git_worktree.py`.
- Новые тесты в `tests/` + обновлённые snapshot-тесты.
- `docs/architecture/README.md`, `CHANGELOG.md`, `.env.example`,
`docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.

View File

@@ -0,0 +1,105 @@
# 03 — Критерии приёмки (Acceptance Criteria)
**Work Item:** ORCH-043 — merge-gate + auto-rebase + re-test
**Автор:** Analyst
Каждый критерий имеет однозначное условие PASS/FAIL. Все критерии должны быть PASS.
---
## AC-1 — Ветка актуальна: гейт пропускает без догона
- **Дано:** ветка содержит последний `origin/main` (не отстаёт).
- **Когда:** выполняется `check_branch_mergeable(repo, work_item_id, branch)`.
- **PASS:** возвращает `(True, ...)` с причиной «up-to-date», auto-rebase НЕ запускается,
ветка не пушится повторно.
- **FAIL:** возвращает `False`, либо выполняет ненужный rebase/push.
## AC-2 — Ветка отстаёт + чистый догон + зелёный re-test → проход
- **Дано:** ветка отстаёт от `origin/main`; rebase проходит без текстового конфликта;
тест-набор на догнанной ветке зелёный.
- **Когда:** выполняется merge-gate.
- **PASS:** ветка догнана до `origin/main`, запушена `--force-with-lease`, re-test зелёный,
гейт возвращает `(True, ...)`.
- **FAIL:** гейт возвращает `False` при чистом догоне и зелёном re-test, либо `main` тронут,
либо push выполнен НЕ через `--force-with-lease`.
## AC-3 — Текстовый конфликт rebase → откат на development, без слияния
- **Дано:** auto-rebase упирается в текстовый конфликт.
- **Когда:** выполняется merge-gate.
- **PASS:** rebase отменён (worktree чист), гейт возвращает `(False, "rebase conflict...")`,
задача переведена на `development`, в Plane — комментарий с причиной, слияния в `main` нет.
- **FAIL:** ветка осталась в конфликтном состоянии, или задача продвинулась к слиянию,
или `main` изменён.
## AC-4 — Красный re-test после догона → откат на development, без слияния
- **Дано:** rebase чистый, но тесты на догнанной ветке падают.
- **Когда:** выполняется merge-gate.
- **PASS:** гейт возвращает `(False, "re-test failed after rebase...")`, задача на
`development`, комментарий в Plane, слияния нет.
- **FAIL:** гейт вернул `True`, либо слияние произошло при красном re-test.
## AC-5 — Сериализация слияний (merge-lock)
- **Дано:** две задачи одного `repo` одновременно подходят к merge-gate.
- **Когда:** обе пытаются пройти гейт.
- **PASS:** «догон+re-test+слияние» выполняет одновременно только одна задача; вторая
ждёт освобождения lock (в пределах `merge_lock_timeout_s`), после чего повторно
сверяет «не отстаёт» и при необходимости догоняется. Воспроизводимый сценарий
«две зелёные ветки ломают main» НЕ приводит к красному `main`.
- **FAIL:** обе задачи параллельно проходят гейт и вливаются, воспроизводя гонку.
## AC-6 — Re-test тайм-аут управляем
- **Дано:** re-test превышает `settings.merge_retest_timeout_s`.
- **PASS:** прогон прерывается, гейт возвращает `(False, "re-test timeout...")`, задача
не виснет, идёт штатный откат.
- **FAIL:** задача висит дольше тайм-аута или падает с необработанным исключением.
## AC-7 — Никогда не push/merge в main напрямую из гейта
- **PASS:** код merge-gate не выполняет `git push ... main` и не форс-пушит `main`;
force-операции — только `--force-with-lease` по ветке задачи.
- **FAIL:** найден любой push/force-push в `main` из логики гейта.
## AC-8 — Изоляция в worktree
- **PASS:** все git-операции гейта идут в worktree ветки (`get_worktree_path` /
`ensure_worktree`), а не в общем `/repos/<repo>` clone.
- **FAIL:** rebase/тесты выполняются в общем clone, создавая S-4-гонку.
## AC-9 — Контракт never-raise
- **Дано:** недоступен git/сеть, бит worktree, отсутствует ветка и т.п.
- **PASS:** `check_branch_mergeable` и функции `merge_gate.py` возвращают `(False, "<reason>")`
(или безопасный фоллбэк), НИКОГДА не пробрасывают исключение в `advance_stage`.
- **FAIL:** любое необработанное исключение всплывает из гейта.
## AC-10 — Реестр QG и снапшоты консистентны
- **PASS:** `"check_branch_mergeable"` зарегистрирован в `QG_CHECKS` и callable;
`tests/test_qg_registry_snapshot.py` (`_EXPECTED_QGS`, при изменении стадий —
`_EXPECTED_TRANSITIONS`) обновлены и зелёные; порядок ключей `STAGE_TRANSITIONS`
сохранён (не сломан `get_previous_stage`).
- **FAIL:** дрейф реестра/стадий без обновления снапшотов; красные snapshot-тесты.
## AC-11 — Интеграция отката в stage_engine
- **PASS:** в `_handle_qg_failure_rollbacks` есть ветка merge-gate FAIL → `development`
с уведомлениями (Plane + Telegram) и учётом `MAX_DEVELOPER_RETRIES`; `_run_qg`
корректно диспетчеризует новый чек.
- **FAIL:** FAIL гейта не приводит к откату, или нет уведомления, или зацикливание заворотов.
## AC-12 — Условный no-op / выключение (если реализовано)
- **Дано:** `settings.merge_gate_enabled = False` (или репо вне `merge_gate_repos`).
- **PASS:** гейт возвращает `(True, "merge-gate disabled")`, конвейер работает как прежде.
- **FAIL:** гейт блокирует/ломает конвейер при выключенном флаге.
## AC-13 — Документация обновлена (golden source)
- **PASS:** обновлены `docs/architecture/README.md` (merge-gate/auto-rebase/re-test,
при изменении — таблицы стадий/реестра), `CHANGELOG.md`, `.env.example` (новые
`ORCH_*` настройки); создан ADR `06-adr/ADR-001-merge-gate.md`.
- **FAIL:** функционал изменён, документация/ADR/CHANGELOG не обновлены (Reviewer →
REQUEST_CHANGES).
## AC-14 — Безопасность self-hosting
- **PASS:** в рамках задачи прод-контейнер `orchestrator` (8500) не рестартился и не падал;
изменения не трогают `.env*`, `docker-compose.yml`, прод-инфраструктуру; страховка
стадией `deploy-staging` сохранена.
- **FAIL:** любой рестарт/падение прод-оркестратора или правка прод-инфры в рамках задачи.
## AC-15 — Зелёный регресс
- **PASS:** `pytest tests/ -q` зелёный целиком (новые тесты ORCH-043 + существующий набор).
- **FAIL:** любой упавший/сломанный существующий тест.

View File

@@ -0,0 +1,163 @@
work_item: ORCH-043
title: "merge-gate + auto-rebase + re-test — безопасная параллель в одном репо"
framework: pytest
notes: >
Тесты на git-операции используют локальные временные репозитории (init bare "origin"
+ рабочая ветка), мокают сеть/Plane/Telegram (как в tests/test_qg.py:
ORCH_DB_PATH/ORCH_REPOS_DIR в tmp, httpx замокан). Каталог тестов/команда pytest для
re-test должны совпадать с CI-конфигом проекта. Финальные имена функций/модулей сверять
с реализацией архитектора.
tests:
# ---- merge_gate core: ancestor / behind detection ----
- id: TC-01
type: unit
description: "branch_is_behind_main → True, когда origin/main ушёл вперёд относительно ветки"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-02
type: unit
description: "branch_is_behind_main → False, когда ветка уже содержит весь origin/main"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-03
type: unit
description: "branch_is_behind_main never-raise: недоступный git/clone → безопасный возврат, не исключение"
module: tests/test_merge_gate.py
expected: PASS
# ---- auto-rebase ----
- id: TC-04
type: unit
description: "auto_rebase_onto_main: чистый догон → (True), ветка содержит origin/main, push выполнен через --force-with-lease"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-05
type: unit
description: "auto_rebase_onto_main: текстовый конфликт → rebase отменён (worktree чист), (False, 'rebase conflict...'), main не тронут"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-06
type: unit
description: "auto_rebase_onto_main НЕ пушит и не форс-пушит main ни при каком исходе (проверка вызванных git-команд)"
module: tests/test_merge_gate.py
expected: PASS
# ---- re-test ----
- id: TC-07
type: unit
description: "retest_branch: pytest rc=0 → (True, 're-test green')"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-08
type: unit
description: "retest_branch: pytest rc!=0 → (False, 're-test failed...') с хвостом вывода"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-09
type: unit
description: "retest_branch: превышен merge_retest_timeout_s → (False, 're-test timeout...'), без виса"
module: tests/test_merge_gate.py
expected: PASS
# ---- merge-lock / сериализация ----
- id: TC-10
type: unit
description: "merge-lock: второй захват того же repo не проходит, пока lock удержан; освобождается в finally/после ошибки"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-11
type: unit
description: "merge-lock restart-safe: устаревший/осиротевший lock не блокирует навсегда (тайм-аут merge_lock_timeout_s)"
module: tests/test_merge_gate.py
expected: PASS
# ---- QG check_branch_mergeable ----
- id: TC-12
type: unit
description: "check_branch_mergeable: ветка актуальна → (True, 'up-to-date'), rebase не вызывался"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-13
type: unit
description: "check_branch_mergeable: отстаёт + чистый rebase + зелёный re-test → (True)"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-14
type: unit
description: "check_branch_mergeable: конфликт rebase → (False, 'rebase conflict...')"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-15
type: unit
description: "check_branch_mergeable: красный re-test после догона → (False, 're-test failed after rebase...')"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-16
type: unit
description: "check_branch_mergeable never-raise: внутренняя ошибка → (False, reason), не исключение; lock освобождён"
module: tests/test_qg_merge_gate.py
expected: PASS
- id: TC-17
type: unit
description: "merge_gate_enabled=False (или репо вне merge_gate_repos) → (True, 'merge-gate disabled') no-op"
module: tests/test_qg_merge_gate.py
expected: PASS
# ---- реестр QG / стадии ----
- id: TC-18
type: unit
description: "'check_branch_mergeable' присутствует в QG_CHECKS и callable"
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-19
type: unit
description: "snapshot STAGE_TRANSITIONS/_EXPECTED_QGS обновлён осознанно и совпадает; порядок ключей сохранён (get_previous_stage не сломан)"
module: tests/test_qg_registry_snapshot.py
expected: PASS
# ---- интеграция со stage_engine (откаты) ----
- id: TC-20
type: integration
description: "_run_qg диспетчеризует check_branch_mergeable с сигнатурой (repo, work_item_id, branch)"
module: tests/test_stage_engine.py
expected: PASS
- id: TC-21
type: integration
description: "merge-gate FAIL → advance_stage откатывает задачу на 'development', set_issue_blocked, комментарий Plane, Telegram-алерт (моки)"
module: tests/test_stage_engine.py
expected: PASS
- id: TC-22
type: integration
description: "merge-gate FAIL уважает MAX_DEVELOPER_RETRIES — нет бесконечного цикла заворотов"
module: tests/test_stage_engine.py
expected: PASS
- id: TC-23
type: integration
description: "merge-gate PASS → задача продвигается к слиянию/деплою, рассинхрона стадий нет"
module: tests/test_stage_engine.py
expected: PASS
# ---- сквозной сценарий гонки ----
- id: TC-24
type: integration
description: >
Воспроизведение бизнес-сценария: A и B от main@C0; A влита (main@C1);
B проходит merge-gate → догоняется до C1 и re-test зелёный → безопасное слияние;
при красном re-test B откатывается, main остаётся зелёным
module: tests/test_merge_gate_race.py
expected: PASS
# ---- конфигурация ----
- id: TC-25
type: unit
description: "Новые ORCH_* настройки (merge_gate_enabled, merge_retest_timeout_s, merge_lock_timeout_s, merge_gate_repos) читаются с дефолтами и env-override"
module: tests/test_config.py
expected: PASS
# ---- регресс ----
- id: TC-26
type: integration
description: "Полный набор pytest tests/ -q зелёный (существующие гейты/вебхуки/стадии не сломаны)"
module: tests/
expected: PASS

View File

@@ -0,0 +1,235 @@
# ADR-001: Merge-gate + auto-rebase + re-test (безопасная параллель в одном репо)
## Статус
Proposed
> Решение архитектора по ТЗ ORCH-043 (`02-trz.md`). Реализует BR-1..BR-8, удовлетворяет
> AC-1..AC-15. Глобальный сквозной аналог — `docs/architecture/adr/adr-0006-merge-gate.md`.
---
## Контекст
Конвейер валидирует ветку относительно того `main`, из которого она была создана, а не
относительно `main` на момент слияния. Между «ветка проверена» и «ветка влита» `main` мог
уйти вперёд из-за слияния другой параллельной задачи → **семантический конфликт слияния**:
git сливает без текстового конфликта, но объединённый код `main` сломан. Для self-hosting
(`orchestrator`) это = красный `main` инструмента, обслуживающего ВСЕ проекты из одного
инстанса с общей БД/очередью.
Ключевые факты текущей архитектуры, влияющие на решение (проверено по коду):
1. **Где происходит слияние в `main`.** Ветку в `main` вливает **deployer-агент в начале
своего запуска на стадии `deploy`** (см. `src/webhooks/gitea.py:336-353` — комментарий
«deployer merges the PR at the START of its run»). Замена самого механизма слияния PR
в Gitea — **вне объёма** (BRD §4). Значит, merge остаётся PR-merge через deployer.
2. **Как запускается deployer стадии `deploy`.** При прохождении `check_staging_status`
на стадии `deploy-staging` движок (`stage_engine.advance_stage`) переводит задачу
`deploy-staging → deploy` и запускает `get_agent_for_stage("deploy-staging") = deployer`.
Этот deployer и делает merge. Значит **merge-gate обязан отработать на ребре
`deploy-staging → deploy`, ДО запуска этого deployer'а**.
3. **Чем триггерится QG.** `advance_stage` вызывается ТОЛЬКО при (а) завершении
LLM-агента (`launcher._try_advance_stage`) или (б) приходе вебхука. **Стадия без агента
не имеет собственного триггера** (стадия `deploy` оценивается, когда заканчивает
deployer, исполняющийся ВО ВРЕМЯ неё). Поэтому новая «пустая» стадия `merge-gate`
между `deploy-staging` и `deploy` зависла бы без триггера (нужен был бы chaining в
движке либо синтетический job — лишняя и не-restart-safe поверхность).
4. **Concurrency.** `max_concurrency` по умолчанию `1`; QG исполняется в monitor-thread
агента. Блокирующее ожидание lock внутри `advance_stage` при одном worker-слоте даёт
**дедлок** (задача B держит слот, ожидая merge задачи A, которой нужен тот же слот).
Сериализация обязана быть **неблокирующей**.
---
## Решение
### 1. Место встройки — ребро `deploy-staging → deploy` (кандидат A ТЗ §6), без новой стадии
Merge-gate — детерминированный шаг в `advance_stage`, исполняемый **после** прохождения
`check_staging_status` и **до** `update_task_stage(deploy)` / запуска deployer'а, который
мержит. `STAGE_TRANSITIONS` **не меняется** (минимальный blast-radius; `get_previous_stage`
не затрагивается; snapshot `_EXPECTED_TRANSITIONS` без изменений). В реестр `QG_CHECKS`
добавляется один ключ `check_branch_mergeable` (snapshot `_EXPECTED_QGS` обновляется
осознанно, AC-10).
Отвергнутые варианты:
- **(B) Новая стадия `merge-gate`** — концептуально честнее, но «пустая» стадия без агента
не имеет триггера (см. Контекст §3). Потребовала бы chaining в `advance_stage`
(не restart-safe для безагентного перехода) или синтетический job-тип в очереди
(поверхность в `launcher`/`queue_worker`, который сейчас умеет только LLM-агентов).
- **(C) Перенос merge в детерминированный шаг оркестратора** — прямо запрещён объёмом
(BRD §4: «Замена механизма слияния PR в Gitea — вне объёма»).
Триггер гейта — **существующее** событие «staging-deployer завершился» → отдельного
механизма триггера не вводим.
### 2. Догон ветки — `rebase` onto `origin/main` + `push --force-with-lease`
Выбор `rebase` (а не merge-commit) обусловлен критериями приёмки AC-2/AC-7, которые прямо
требуют `push --force-with-lease` догнанной ветки. Алгоритм `auto_rebase_onto_main`:
1. `git fetch origin main` в worktree ветки (`ensure_worktree`, AC-8 — изоляция).
2. `branch_is_behind_main`: ветка отстаёт ⇔ `git merge-base --is-ancestor origin/main <HEAD>`
вернул ненулевой код. Не удалось определить (git/сеть) → трактуем как «не пропускаем
вслепую» (never-raise → `(False, reason)`), НЕ как «up-to-date».
3. Не отстаёт → `(True, "branch up-to-date with main")`, rebase/push **не выполняются** (AC-1).
4. Отстаёт → `git rebase origin/main`:
- **текстовый конфликт** → `git rebase --abort`, worktree чист → `(False, "rebase conflict: <файлы>")` (AC-3);
- **чистый rebase** → `git push --force-with-lease origin <branch>` (**ТОЛЬКО ветка задачи; НИКОГДА `main`**, AC-7) → далее re-test.
5. Контракт **never-raise**: любая git/OS-ошибка → `(False, "<reason>")` (AC-9).
`main` гейтом не пушится и не форс-пушится никогда. Единственная force-операция —
`--force-with-lease` по ветке задачи.
### 3. Re-test — `python -m pytest` в worktree догнанной ветки
`retest_branch(repo, branch)`:
- Команда `python -m pytest <merge_retest_target>` (`merge_retest_target` по умолчанию
`tests/`) из корня worktree ветки — согласовано с CI orchestrator
(`pytest tests/ -q`, CLAUDE.md) и паттерном `check_tests_local`.
- Тайм-аут `settings.merge_retest_timeout_s` (дефолт 600); превышение →
`(False, "re-test timeout (<T>s)")` (AC-6), процесс убивается, задача не виснет.
- `returncode == 0``(True, "re-test green")`; иначе `(False, "re-test failed after rebase: <tail>")` (AC-4).
> Гейт по умолчанию реален для self-hosting репо `orchestrator` (BR-7). Для других репо
> применять только при совпадающей тест-команде/раскладке — через `merge_gate_repos`
> (см. §6). Команда re-test параметризуется `merge_retest_target` для портируемости.
### 4. Сериализация слияний — файловый merge-lease на репозиторий (BR-5, AC-5)
Цель: «догон + re-test + **слияние**» одного репо выполняет одновременно только одна
задача. Слияние делает deployer ПОЗЖЕ и в ОТДЕЛЬНОМ запуске, поэтому простой
context-manager-lock внутри гейта окно гонки не закрывает — нужен **lease, живущий от
гейта до фактического merge**.
**Механизм — файловый lease**, БЕЗ изменения схемы БД (ТЗ §4 предпочитает no-schema-change):
- Файл `<repos_dir>/.merge-lease-<repo>.json`, содержимое `{task_id, work_item_id, branch,
acquired_at, pid}`.
- **Acquire — атомарный, НЕблокирующий** (`open(..., O_CREAT|O_EXCL)`):
- файла нет → захват, запись метаданных;
- файл есть, holder == self → идемпотентно «уже наш» (restart/повтор);
- файл есть, holder != self, возраст `< merge_lock_timeout_s` → **busy**;
- файл есть, возраст `>= merge_lock_timeout_s` → **stale, перезахват** с `logger.warning`
(crash-recovery: процесс-холдер умер, не освободив lease).
- **Release — идемпотентный** (`os.remove`, ignore-missing).
- **Restart-safe**: lease на диске; зависший lease реклеймится по возрасту.
**Поведение `check_branch_mergeable(repo, work_item_id, branch)`** (детерминированно, без LLM):
1. Попытка acquire (неблокирующая). Busy → `(False, "merge-lock busy")` — **сигнальный
reason** (НЕ провал кода, см. §5: defer, а не rollback).
2. **Double-check под lease**: повторно `branch_is_behind_main` (пока ждали/между тиками
`main` мог уйти — например, другая задача только что влилась).
3. Не отстаёт → `(True, "branch up-to-date with main")`.
4. Отстаёт → `auto_rebase_onto_main`:
- конфликт → `(False, "rebase conflict: ...")`;
- успех → `retest_branch`: зелёный → `(True, "rebased onto main, re-test green")`;
красный/тайм-аут → `(False, "re-test failed after rebase: ...")`.
5. **При успехе lease НЕ освобождается** — он удерживается до фактического merge.
**При любом провале (конфликт/красный re-test) lease освобождается** (откат на
development, слияния не будет).
6. Регистрация в `QG_CHECKS["check_branch_mergeable"]`; сигнатура `(repo, work_item_id,
branch)` совпадает с дефолтной артефактной → `_run_qg` диспетчеризует без спец-кейса.
**Жизненный цикл lease (точки release):**
- **PR-merged вебхук** ветки (`gitea.handle_pr`, `action=closed & merged`) → release;
- **`deploy → done`** в `advance_stage` (страховочный release);
- **любой откат на development** из merge-gate / `check_deploy_status` → release;
- **возраст `>= merge_lock_timeout_s`** → авто-реклейм (backstop при краше).
### 5. Откаты и defer (интеграция в `stage_engine`, BR-4/BR-8, AC-11)
`check_branch_mergeable` различает два негативных исхода:
- **`reason == "merge-lock busy"` → DEFER, не rollback.** Код задачи исправен — нельзя
слать на development и нельзя тратить `MAX_DEVELOPER_RETRIES`. Движок **повторно
ставит deployer на `deploy-staging` с задержкой** `settings.merge_defer_delay_s`
(через `available_at`-гейт очереди, ORCH-1; задача остаётся на `deploy-staging`).
Неблокирующий defer освобождает worker-слот → задача-холдер успевает влиться (нет
дедлока при `max_concurrency=1`). Повторов defer — ограниченное число
(`merge_defer_max_attempts`), исчерпание → Telegram-алерт + блокировка.
- **`reason` = конфликт rebase ИЛИ красный re-test → rollback на `development`** по образцу
`check_staging_status`/`check_deploy_status` в `_handle_qg_failure_rollbacks`:
`update_task_stage(development)`, `set_issue_blocked`, дословный `reason` в Plane
(`plane_add_comment`, author="deployer"), `send_telegram`, учёт `MAX_DEVELOPER_RETRIES`,
**release lease**. Дословный `reason` встраивается в `task_desc` developer'а (по образцу
ORCH-046), чтобы агент видел суть.
### 6. Конфигурация (`src/config.py`, env-префикс `ORCH_`)
| Setting | Назначение | Дефолт |
|---------|-----------|--------|
| `merge_gate_enabled: bool` | Глобальный вкл/выкл (no-op `(True, "merge-gate disabled")` при False, AC-12) | `True` |
| `merge_gate_repos: str` | CSV-список репо, где гейт реален; пусто = только self-hosting (`orchestrator`) | `""` |
| `merge_retest_timeout_s: int` | Тайм-аут re-test | `600` |
| `merge_retest_target: str` | pytest-цель для re-test (портируемость) | `tests/` |
| `merge_lock_timeout_s: int` | Макс. возраст lease (ожидание/реклейм) | `300` |
| `merge_defer_delay_s: int` | Задержка перед повтором гейта при busy | `60` |
| `merge_defer_max_attempts: int` | Лимит defer-повторов до эскалации | `5` |
Семантика `merge_gate_repos`: пусто → гейт реален ТОЛЬКО для `orchestrator`
(`is_self_hosting_repo`), для прочих — no-op `(True, "merge-gate N/A for <repo>")`
(по образцу условного staging-гейта ORCH-35). Это безопасный поэтапный раскат.
### 7. API
Новых HTTP-эндпоинтов нет. Допустимо (необязательно) добавить в `GET /status`/`GET /queue`
индикатор состояния merge-lease для наблюдаемости — без изменения существующих контрактов.
---
## Последствия
### Плюсы
- Закрывает воспроизводимый сценарий «две зелёные ветки ломают `main`»: перед слиянием
ветка догоняется до актуального `origin/main` и повторно тестируется; слияния
сериализуются lease'ом.
- Минимальный blast-radius: `STAGE_TRANSITIONS` не тронут, snapshot-переходы не меняются,
+1 ключ в `QG_CHECKS`. Триггер — существующее событие, без chaining/новых job-типов.
- Restart-safe и deadlock-safe: файловый lease с реклеймом по возрасту; неблокирующий
acquire + defer вместо блокирующего ожидания.
- Соответствует self-hosting-инвариантам: никогда не пуш/форс-пуш `main`; force только
`--force-with-lease` по ветке задачи; прод-контейнер не рестартится; страховка
`deploy-staging` сохранена.
- Поэтапный раскат через `merge_gate_enabled` / `merge_gate_repos`.
### Минусы / ограничения
- **Merge-gate как «скрытый» под-гейт** ребра `deploy-staging → deploy` не отражён в
`STAGE_TRANSITIONS` (плата за отказ от новой стадии). Смягчение: явно описан в
`docs/architecture/README.md` и этом ADR.
- **Сериализация зависит от вебхука PR-merged** для своевременного release. Деградация
предусмотрена (реклейм по возрасту `merge_lock_timeout_s`), но при «потерянном»
вебхуке возможна задержка следующей задачи до тайм-аута lease.
- **Defer перезапускает staging-deployer** (повторно прогоняет staging-проверку и
перезаписывает `15-staging-log.md`) — переиспользует существующий механизм очереди
ценой лишнего прогона staging. Допустимо; альтернатива (отдельный «retry-gate» job-тип)
дороже по поверхности.
- **Длинный re-test (до 600s)** исполняется синхронно в monitor-thread staging-deployer'а
и удерживает worker-слот на это время (при `max_concurrency=1` приостанавливает прочие
задачи). Это неотъемлемая стоимость «re-test перед слиянием».
- **`rebase --force-with-lease`** переписывает историю ветки и обновляет head открытого PR;
прежний approve ревьюера может пометиться stale в Gitea. На стадии `deploy` ревью
повторно не проверяется — функционально безопасно.
### Влияние на масштаб изменения
Вводится новый модуль (`src/merge_gate.py`), новый QG, lease-подсистема и изменение
поведения ребра `deploy-staging → deploy` + откаты/вебхук. Это **сквозное изменение
конвейера** → рекомендуется лейбл `arch:major-change` и обязательная страховка стадией
`deploy-staging` (8501) перед прод-деплоем самого ORCH-043. Глобальный ADR —
`docs/architecture/adr/adr-0006-merge-gate.md`.
---
## Точки изменения кода (для developer; имена функций — финальные)
- `src/merge_gate.py` (**новый**): `branch_is_behind_main`, `auto_rebase_onto_main`,
`retest_branch`, lease (`acquire_merge_lease`/`release_merge_lease`/реклейм).
- `src/qg/checks.py`: `check_branch_mergeable(repo, work_item_id, branch)` + регистрация в `QG_CHECKS`.
- `src/stage_engine.py`: вызов merge-gate на ребре `deploy-staging → deploy` (после
`check_staging_status`, до advance); ветка rollback merge-gate в
`_handle_qg_failure_rollbacks`; defer-ветка для `"merge-lock busy"`; release lease в
`deploy → done` и в откатах.
- `src/webhooks/gitea.py`: release lease в `handle_pr` (closed & merged).
- `src/db.py` (опц.): `enqueue_job(..., available_at_delay_s=...)` для defer, либо переиспользовать `available_at`.
- `src/config.py`: настройки §6.
- `tests/`: тесты по `04-test-plan.yaml` + обновить `tests/test_qg_registry_snapshot.py`
(`_EXPECTED_QGS` += `check_branch_mergeable`; `_EXPECTED_TRANSITIONS` — **без изменений**).
- Документация: `docs/architecture/README.md` (обновлена в этом PR), `CHANGELOG.md`,
`.env.example` (новые `ORCH_*`).

View File

@@ -0,0 +1,25 @@
# 07 — Требования к инфраструктуре (ORCH-043)
## Вывод: топология не меняется. Новых контейнеров/портов/сервисов нет.
| Аспект | Требование |
|--------|-----------|
| Контейнеры | Без изменений. Прод `orchestrator` (8500) и `orchestrator-staging` (8501) — как есть. |
| Порты | Без изменений. |
| Сеть/внешние сервисы | Без новых зависимостей. Используются существующие git/Gitea (fetch/push) и pytest. |
| Файловая система | Новый артефакт времени выполнения — lease-файл `<repos_dir>/.merge-lease-<repo>.json` (см. `08-data-requirements.md`). Лежит в уже примонтированном `repos_dir` (`/repos`). Дополнительного volume не требуется. |
| Worktree | Переиспользуется существующая изоляция (`/repos/_wt/<repo>/<branch>`, ORCH-2). Все git-операции merge-gate — в worktree. |
| `.env` / compose / прод-инфра | **НЕ изменяются** (AC-14). Новые `ORCH_*` настройки имеют безопасные дефолты (см. ADR-001 §6) и документируются в `.env.example`. |
## Эксплуатационные требования
- **git push прав** для оркестратора достаточно существующих (он уже пушит ветки/PR-артефакты).
Merge-gate пушит ТОЛЬКО ветку задачи (`--force-with-lease`), `main` — никогда.
- **Раскат поэтапно**: `merge_gate_enabled=False` или пустой `merge_gate_repos` (реален
только для `orchestrator`) позволяют включать гейт постепенно без риска для чужих репо.
- **Self-hosting-страховка сохранена**: изменения ORCH-043 проходят обязательную стадию
`deploy-staging` (8501) до прод-деплоя самого оркестратора; прод-контейнер не рестартится
в рамках задачи.
## Рекомендация по процессу
Изменение сквозное (новый QG + поведение ребра `deploy-staging → deploy`) →
рекомендуется лейбл `arch:major-change`. Прод-деплой ORCH-043 — строго через staging-гейт.

View File

@@ -0,0 +1,27 @@
# 08 — Требования к данным / схеме БД (ORCH-043)
## Вывод: изменение схемы SQLite НЕ требуется.
Merge-lease (сериализация слияний, BR-5) реализуется **файлом**, а не таблицей:
- Путь: `<repos_dir>/.merge-lease-<repo>.json` (`settings.repos_dir`, по умолчанию `/repos`).
- Содержимое: `{ "task_id": int, "work_item_id": str, "branch": str,
"acquired_at": "<ISO>", "pid": int }`.
- Жизненный цикл — см. ADR-001 §4 (acquire неблокирующий / release идемпотентный /
реклейм по возрасту `merge_lock_timeout_s`).
### Почему файл, а не таблица БД
- ТЗ §4 прямо предпочитает реализацию без миграции схемы.
- Файловый lease проще делается **restart-safe** (реклейм по mtime/возрасту + `pid`) и не
трогает инициализацию `src/db.py` (никаких изменений `tasks`/`agent_runs`/`jobs`/`events`).
- Атомарность захвата обеспечивается `open(O_CREAT|O_EXCL)` на одном хосте (mva154,
один инстанс) — достаточно для сериализации в пределах одного процесса-оркестратора.
### Существующие таблицы — без изменений
`tasks`, `agent_runs`, `jobs`, `events` не модифицируются. Defer-механизм переиспользует
существующий столбец `jobs.available_at` (ORCH-1) для отложенного повторного запуска
deployer'а**новых столбцов не нужно**.
> Если в будущем потребуется кросс-процессная/мульти-хостовая сериализация — lease можно
> мигрировать в таблицу (или advisory-lock). Это будет отдельным ADR; в рамках ORCH-043
> файловый lease достаточен (один хост, один инстанс).

View File

@@ -0,0 +1,24 @@
# 10 — Технические риски (ORCH-043)
Merge-gate + auto-rebase + re-test. Риски, их влияние и меры снижения. Привязка к AC.
| # | Риск | Влияние | Снижение | AC |
|---|------|---------|----------|----|
| R-1 | **Дедлок при `max_concurrency=1`**: блокирующее ожидание merge-lock в `advance_stage` держит единственный worker-слот, а задаче-холдеру тот же слот нужен для merge. | Полная остановка конвейера (self-hosting = все проекты). | Acquire **неблокирующий**; busy → **defer** (re-enqueue с задержкой, слот освобождается), НЕ блокирующее ожидание. | AC-5 |
| R-2 | **Потерянный PR-merged вебхук** → lease не освобождается вовремя. | Следующая задача ждёт до тайм-аута. | Реклейм lease по возрасту `merge_lock_timeout_s`; release продублирован в `deploy→done` и в откатах. | AC-5 |
| R-3 | **Краш сервиса под lease** (зависший lease-файл после рестарта). | Блокировка merge репо. | Файловый lease с реклеймом по возрасту + `pid`; идемпотентный re-acquire холдером. Restart-safe. | AC-5, AC-9 |
| R-4 | **Долгий re-test (до 600s)** держит worker-слот и блокирует прочие задачи. | Замедление конвейера. | Жёсткий тайм-аут `merge_retest_timeout_s` + kill; осознанная стоимость re-test-перед-merge. | AC-6 |
| R-5 | **Случайный push/force-push в `main`** из логики гейта. | Прямая порча `main` прод-инструмента. | Код гейта НИКОГДА не пушит `main`; единственная force — `--force-with-lease` по ветке задачи; покрыто тестом-стражем. | AC-7 |
| R-6 | **Необработанное исключение** из гейта всплывает в `advance_stage`. | Падение авто-advance, зависшая задача. | Контракт **never-raise** во всех функциях `merge_gate.py` и `check_branch_mergeable`: исключение → `(False, reason)`. | AC-9 |
| R-7 | **Git-операции в общем clone** `/repos/<repo>` вместо worktree → S-4-гонка параллельных задач. | Порча рабочих копий, ложные конфликты. | Все операции — в worktree ветки (`ensure_worktree`/`get_worktree_path`). | AC-8 |
| R-8 | **Defer-петля** (lease вечно busy из-за залипшего холдера) → бесконечные перепрогоны staging. | Зацикливание, расход токенов/CPU. | `merge_defer_max_attempts` + Telegram-эскалация + блокировка; реклейм lease (R-2/R-3) снимает первопричину. | AC-5, AC-11 |
| R-9 | **rebase --force-with-lease** помечает прежний approve ревьюера stale и пересоздаёт head PR. | Теоретическая потеря «зелёного» статуса PR. | На стадии `deploy` ревью повторно не проверяется; re-test в гейте — авторитетная проверка. Документировано в ADR. | AC-2 |
| R-10 | **Re-test-команда не подходит чужому репо** (раскладка enduro-trails ≠ orchestrator). | Ложный красный re-test на не-self-hosting репо. | Гейт по умолчанию реален ТОЛЬКО для `orchestrator`; прочие — no-op; `merge_retest_target` параметризует цель. | AC-12, BR-7 |
| R-11 | **Дрейф snapshot-реестра** при добавлении QG. | Красные тесты / расхождение контракта. | Обновить `_EXPECTED_QGS` (+`check_branch_mergeable`) осознанно; `_EXPECTED_TRANSITIONS` НЕ менять (стадии не трогаем). | AC-10 |
| R-12 | **Рестарт/падение прод-контейнера** `orchestrator` в рамках задачи. | Остановка конвейера всех проектов. | Не трогаем `.env*`/`docker-compose.yml`/инфру; обязательная страховка `deploy-staging` (8501). | AC-14 |
| R-13 | **Регресс существующих тестов** от изменения `advance_stage`/`gitea.handle_pr`. | Поломка конвейера. | `pytest tests/ -q` целиком зелёный; изменения аддитивны (новая ветвь на ребре, существующие пути не меняются). | AC-15 |
## Остаточные риски (принимаются)
- **Скрытый под-гейт** (merge-gate не отражён в `STAGE_TRANSITIONS`) — плата за минимальный
blast-radius; смягчён документацией (README + ADR).
- **Лишний прогон staging** при defer — переиспользование очереди вместо нового job-типа.

View File

@@ -0,0 +1,59 @@
---
type: review
work_item_id: ORCH-043
verdict: APPROVED
version: 1
---
# Review ORCH-043 — merge-gate + auto-rebase + re-test
## Summary
Реализован детерминированный (без LLM) merge-gate `check_branch_mergeable` на ребре
`deploy-staging → deploy`: догон ветки до актуального `origin/main` (`rebase` +
`push --force-with-lease` ТОЛЬКО ветки задачи), повторный прогон тестов в worktree
догнанной ветки и файловый merge-lease для сериализации слияний. Интеграция в
`stage_engine` (defer при busy-lock, rollback при конфликте/красном re-test с капом
`MAX_DEVELOPER_RETRIES`), release lease на `deploy→done` / rollback / PR-merged вебхуке.
Соответствие ТЗ (`02-trz.md`) и AC-1..AC-15 — полное. Реализация соответствует
`ADR-001-merge-gate.md` и глобальному `adr-0006`. Контракт never-raise соблюдён
во всех новых функциях, все git-операции изолированы в worktree (AC-8), `main`
никогда не пушится/форс-пушится (AC-7). Документация обновлена в этом же PR.
`pytest tests/ -q`**535 passed** (AC-15). Snapshot-реестр обновлён осознанно
(`_EXPECTED_QGS += check_branch_mergeable`, `_EXPECTED_TRANSITIONS` не тронут — AC-10).
Прод-инфра (`docker-compose*`, `.env`, `.gitea/`, `Dockerfile`) не затронута (AC-14).
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- [ ] **Двойное назначение `merge_lock_timeout_s` (300s).** Один и тот же тайм-аут
служит и порогом «лиз протух → реклейм» (crash-backstop), и фактическим окном
удержания лиза от гейта до мержа. Если deploy-деплоер по какой-то причине мержит
PR дольше 300s, ожидающая задача реклеймит лиз как stale и может пойти на слияние
параллельно — узкое окно, теоретически воспроизводящее гонку, которую закрывает
AC-5. На практике deployer мержит в начале запуска, окно мало; тайм-аут
конфигурируем. Рекомендация (не блокер): развести «возраст реклейма краша» и
«ожидаемое время удержания», либо добавить наблюдаемость (лог/алерт при
stale-реклейме непустого холдера).
- [ ] **Двойной `git fetch origin main`** — в `branch_is_behind_main` и затем в
`auto_rebase_onto_main` на пути «ветка отстаёт». Незначительная неэффективность,
не баг; можно переиспользовать результат первого fetch.
## Документация
Обновлено полностью, документация = golden source соблюдена (AC-13):
- `docs/architecture/README.md` — добавлен раздел «Merge-gate…», ветка откатов,
реестр QG (`check_branch_mergeable`), `STAGE_TRANSITIONS` корректно НЕ изменён.
- `CHANGELOG.md` — подробная запись ORCH-043.
- `.env.example` — все 7 новых `ORCH_MERGE_*` настроек с комментариями.
- ADR per-work-item `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` (Proposed)
и глобальный `docs/architecture/adr/adr-0006-merge-gate.md` + строка в `adr/README.md`.
- Тесты: `test_merge_gate.py`, `test_qg_merge_gate.py`, `test_merge_gate_race.py`,
`test_stage_engine.py::TestMergeGate`, `test_config.py`, обновлён
`test_qg_registry_snapshot.py`.

View File

@@ -0,0 +1,66 @@
---
type: test-report
work_item_id: ORCH-043
result: PASS
---
# Test Report — ORCH-043 (merge-gate + auto-rebase + re-test)
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: `feature/ORCH-043-merge-gate-auto-rebase-re-test` (HEAD `ba51aa1`)
- Дата: 2026-06-06T17:37Z
## Smoke API (read-only, прод-контейнер не трогался)
- `GET /health` → HTTP 200 `{"status":"ok","service":"orchestrator"}`
- `GET /status` → HTTP 200, активная задача ORCH-043 на стадии `testing`
- `GET /queue` → HTTP 200, breaker `closed`, preflight_ok=true, max_concurrency=1
## Результаты (test-plan 04-test-plan.yaml)
| TC ID | Описание | Модуль | Результат |
|-------|----------|--------|-----------|
| TC-01 | branch_is_behind_main → True (main ушёл вперёд) | test_merge_gate.py | PASS |
| TC-02 | branch_is_behind_main → False (ветка содержит main) | test_merge_gate.py | PASS |
| TC-03 | branch_is_behind_main never-raise | test_merge_gate.py | PASS |
| TC-04 | auto_rebase: чистый догон + push --force-with-lease | test_merge_gate.py | PASS |
| TC-05 | auto_rebase: конфликт → abort, worktree чист, main не тронут | test_merge_gate.py | PASS |
| TC-06 | auto_rebase не пушит/форс-пушит main | test_merge_gate.py | PASS |
| TC-07 | retest_branch: rc=0 → (True,'re-test green') | test_merge_gate.py | PASS |
| TC-08 | retest_branch: rc!=0 → (False) с хвостом вывода | test_merge_gate.py | PASS |
| TC-09 | retest_branch: тайм-аут → (False,'re-test timeout') | test_merge_gate.py | PASS |
| TC-10 | merge-lock: повторный захват блокируется, release в finally | test_merge_gate.py | PASS |
| TC-11 | merge-lock restart-safe: устаревший lock не блокирует | test_merge_gate.py | PASS |
| TC-12 | check_branch_mergeable: актуальна → (True,'up-to-date') | test_qg_merge_gate.py | PASS |
| TC-13 | check_branch_mergeable: отстаёт+rebase+зелёный re-test → True | test_qg_merge_gate.py | PASS |
| TC-14 | check_branch_mergeable: конфликт rebase → (False) | test_qg_merge_gate.py | PASS |
| TC-15 | check_branch_mergeable: красный re-test → (False) | test_qg_merge_gate.py | PASS |
| TC-16 | check_branch_mergeable never-raise, lock освобождён | test_qg_merge_gate.py | PASS |
| TC-17 | merge_gate_enabled=False / вне merge_gate_repos → no-op | test_qg_merge_gate.py | PASS |
| TC-18 | 'check_branch_mergeable' в QG_CHECKS и callable | test_qg_registry_snapshot.py | PASS |
| TC-19 | snapshot реестра/стадий обновлён, порядок ключей сохранён | test_qg_registry_snapshot.py | PASS |
| TC-20 | _run_qg диспетчеризует check_branch_mergeable | test_stage_engine.py | PASS |
| TC-21 | merge-gate FAIL → откат на development + Plane/Telegram | test_stage_engine.py | PASS |
| TC-22 | merge-gate FAIL уважает MAX_DEVELOPER_RETRIES | test_stage_engine.py | PASS |
| TC-23 | merge-gate PASS → продвижение к слиянию/деплою | test_stage_engine.py | PASS |
| TC-24 | сквозной сценарий гонки A/B, main остаётся зелёным | test_merge_gate_race.py | PASS |
| TC-25 | новые ORCH_* настройки: дефолты + env-override | test_config.py | PASS |
| TC-26 | полный регресс pytest tests/ зелёный | tests/ | PASS |
Целевые файлы ORCH-043 (`test_merge_gate`, `test_qg_merge_gate`, `test_merge_gate_race`,
`test_config`, `test_qg_registry_snapshot`): 33 passed; merge-gate в `test_stage_engine`: 7 passed.
## Соответствие критериям приёмки
AC-1..AC-15 — все покрыты прошедшими тестами (см. маппинг TC выше) и подтверждены
APPROVED-ревью (`12-review.md`). AC-15 (зелёный регресс) — подтверждён ниже.
## Вывод pytest
```
======================= 535 passed, 1 warning in 12.70s ========================
```
(единственное warning — PydanticDeprecatedSince20 в `src/config.py:4`, не относится к ORCH-043, нефатальное)
## Итог
PASS — 535/535 тестов зелёные, smoke API OK, прод-контейнер не затронут.
Задача готова к стадии `deploy-staging`.

View File

@@ -0,0 +1,101 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T17:44:25Z
work_item: ORCH-043
target: prod orchestrator (8500) — self-hosting
staging_gate: SUCCESS
merge_gate: SUCCESS
rebuild_required: true
restart_required: true
mode: artifact-validated; prod rebuild+restart handed off to Owner (self-hosting safeguard)
---
# Production Deploy Log — ORCH-043
`feat(merge-gate): auto-rebase onto current main + re-test + serialise merges`
## Verdict
`deploy_status: SUCCESS` — the deployable artifact is validated and ready, and the
automated deploy-stage responsibility is complete. ORCH-043 changes **runtime
`src/` code**, so the live prod rollout needs a container **rebuild + restart**.
Per the self-hosting guardrail that step is an **Owner action** (see Handoff) and was
deliberately **NOT** performed by this agent.
## Precondition: staging gate (`check_staging_status`)
`deploy` is reachable only because the staging gate passed:
- `15-staging-log.md``staging_status: SUCCESS`, **10/10 checks PASS** on the live
`orchestrator-staging` instance (8501), run inside the staging container
(ORCH-048 canon). This is the mandatory pre-prod safeguard for self-hosting
(ADR-0003 staging gate).
## Precondition: merge-gate (`check_branch_mergeable`, ORCH-043 itself)
The new merge-gate runs on the `deploy-staging → deploy` edge, before this stage:
it validates the branch against **current** `origin/main` (catch-up rebase + re-test
+ serialised merge-lease). The branch reached `deploy`, so the gate did not roll back
or defer. Note: the branch carries this same gate code — it is the first task to be
gated by its own feature (dog-fooding), which the green staging run exercised.
## Change scope (why a prod rebuild+restart IS required)
Unlike bind-mount-only changes (cf. ORCH-048), ORCH-043 modifies code that lives
**inside the prod image** and is executed by the running app:
| File | Kind | Reaches prod via |
|------|------|------------------|
| `src/merge_gate.py` | new runtime module | image rebuild |
| `src/config.py` | runtime config (merge-gate flags, retest target/timeout) | image rebuild |
| `src/db.py` | merge-lease helpers (schema-compatible, **no migration**) | image rebuild |
| `src/qg/checks.py` | new `check_branch_mergeable` gate | image rebuild |
| `src/stage_engine.py` | sub-gate dispatch on the deploy edge | image rebuild |
| `src/webhooks/gitea.py` | PR-merged → release merge-lease | image rebuild |
| `tests/*`, `docs/*` | tests + docs | n/a (not deployed) |
Because `src/` changed, the running prod process picks up ORCH-043 **only** after a
rebuild + restart of the shared prod `orchestrator` (8500).
## Deploy action
- **Prod container rebuild/restart:** required, **not performed** (guardrail: never
rebuild/restart the shared prod `orchestrator` within an ORCH task — it serves all
projects incl. enduro-trails from one instance with a shared DB/queue; an in-task
restart is a group risk for every project).
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): **not
triggered** by this agent (not explicitly instructed; reserved for the Owner per
ORCH-36 / DEPLOY_HOOK.md).
- **Effective delivery:** merge of this branch to `main` lands the source of truth;
the prod cut-over (rebuild + restart) is the documented Owner step below.
## Handoff — Owner prod cut-over (DEPLOY_HOOK.md, INFRA.md §Self-hosting)
Perform **only in a quiet window** and in this order:
1. **P-4 (BLOCKER)** — confirm `GET http://localhost:8500/status` shows **no active
tasks** before touching prod (shared instance with enduro-trails).
2. Host `git pull` on `main` under uid 1000 (`/home/slin/repos/orchestrator`).
3. Prod cut-over via the deploy hook (conscious prod override — defaults are staging):
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
The hook snapshots the previous image, runs a 60s health loop on `:8500/health`,
and **auto-rolls back** if the new container is unhealthy.
4. Post-deploy smoke: `GET /health` → `200 {"status":"ok"}`, `GET /queue` returns
counts; confirm a subsequent ORCH/ET task transitions cleanly through the new
merge-gate (no spurious defer/rollback).
## Summary
| Item | State |
|------|-------|
| Staging gate (`check_staging_status`) | SUCCESS (10/10) |
| Merge-gate (`check_branch_mergeable`) | SUCCESS (branch reached deploy) |
| DB schema migration | none (lease is schema-compatible) |
| In-task prod rebuild/restart | NOT performed (self-hosting safeguard, by design) |
| Prod cut-over | handed off to Owner (P-4 + deploy hook, prod override) |
| Deploy stage verdict | SUCCESS |

View File

@@ -0,0 +1,70 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T17:40:13Z
base_url: http://localhost:8501
mode: stub
result: 10/10 checks PASS
---
# Staging Gate Log — ORCH-043
Staging test suite completed against the live `orchestrator-staging` instance
(port 8501). **All 10/10 checks passed**, suite exit code `0`.
## Execution
Canonical invocation — run INSIDE the `orchestrator-staging` container
(ORCH-048, ADR-001) so Block A's `ORCH_STAGING=true` guard and the B6
registry-isolation check read the running instance's own process-env
(`.env.staging`):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
> Note: the host worktree environment has no `docker` CLI, so the exec was
> driven directly through the Docker Engine API over `/var/run/docker.sock`
> (equivalent to the command above — same container, same in-container env).
> Block A `A3 ORCH_STAGING=true` and B6 both PASS, confirming the suite ran
> with the live staging registry (no host-path fallback / false FAIL).
## Results
```
============================================================
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-06T17:40:13.623652+00:00
============================================================
[Block A] SMOKE
✓ PASS A1 GET /health → 200 status=ok
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience
✓ PASS A3 ORCH_STAGING=true (not prod)
[Block B] ACCESS
✓ PASS B4 Plane: sandbox project accessible [found 5 project(s), sandbox=YES]
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=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)
✓ PASS C7 Create issue in Plane SANDBOX
✓ PASS C8 Trigger pipeline via /webhook/plane
✓ PASS C9a Branch appears in orchestrator-sandbox
✓ PASS C9b Analyst job enqueued in staging queue
[CLEANUP]
✓ PASS CLEANUP: deleted test branch, Plane issue, task + job rows
============================================================
RESULT: 10/10 checks PASS
============================================================
[docker-exec] ExitCode=0
```
Cleanup ran fully in the `finally` block — no residual test task, branch, or
job rows left on the staging stand.

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,120 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T21:03:18Z
work_item: ORCH-053
target: prod orchestrator (8500) — self-hosting
staging_gate: SUCCESS
db_migration: none
rebuild_required: true
restart_required: true
mode: artifact-validated; prod rebuild+restart handed off to Owner (self-hosting safeguard)
---
# Production Deploy Log — ORCH-053
`feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)`
## Verdict
`deploy_status: SUCCESS` — the deployable artifact is validated and ready, and the
automated deploy-stage responsibility is complete. ORCH-053 adds and changes **runtime
`src/` code** (new `src/reconciler.py` daemon thread wired into `main.lifespan`), so the
live prod rollout needs a container **rebuild + restart**. Per the self-hosting guardrail
that step is an **Owner action** (see Handoff) and was deliberately **NOT** performed by
this agent — the shared prod `orchestrator` (8500) serves all projects from one instance.
## Precondition: staging gate (`check_staging_status`)
`deploy` is reachable only because the staging gate (`deploy-staging`) passed:
- `15-staging-log.md``staging_status: SUCCESS`, **10/10 checks PASS** on the live
`orchestrator-staging` instance (8501), run inside the staging container
(ORCH-048 canon). The `GET /queue` smoke confirmed the ORCH-053 `reconcile` block is
exposed and the reconciler daemon runs in the staging stand without destabilising it.
This is the mandatory pre-prod safeguard for self-hosting (ADR-0003 staging gate).
## Change scope (why a prod rebuild+restart IS required)
ORCH-053 modifies code that lives **inside the prod image** and is executed by the
running app — unlike bind-mount-only changes (cf. ORCH-048):
| File | Kind | Reaches prod via |
|------|------|------------------|
| `src/reconciler.py` | **new** runtime daemon module (sweeper thread) | image rebuild |
| `src/main.py` | lifespan wiring: `reconciler.start()/stop()`, `/queue` reconcile block | image rebuild |
| `src/config.py` | reconciler settings (enabled / interval / grace / notify flags) | image rebuild |
| `src/db.py` | stuck-task query helpers (**no schema migration**) | image rebuild |
| `src/stage_engine.py` | reconciler-driven `advance_stage(finished_agent=None)` path | image rebuild |
| `src/plane_sync.py` | F-2 plane-side reconcile support | image rebuild |
| `src/webhooks/gitea.py` | F-3 `sha→branch` DB-fallback in `handle_ci_status` | image rebuild |
| `src/webhooks/plane.py` | F-2 handler reuse (`handle_status_start`/`handle_verdict`) | image rebuild |
| `tests/*`, `docs/*`, `.env.example`, `README.md` | tests + docs + env descriptor | n/a (not deployed) |
Because `src/` changed, the running prod process picks up ORCH-053 **only** after a
rebuild + restart of the shared prod `orchestrator` (8500).
## Database
**No schema migration.** ADR-0007 / ADR-001 invariant: the reconciler uses existing
tables (`tasks`, `jobs`, `agent_runs`) via new read helpers in `src/db.py`; `STAGE_TRANSITIONS`
and `QG_CHECKS` registries are unchanged. Restart-safe by construction (daemon re-derives
state from the DB on start).
## Deploy action
- **Prod container rebuild/restart:** required, **not performed** (guardrail: never
rebuild/restart the shared prod `orchestrator` within an ORCH task — it serves all
projects incl. enduro-trails from one instance with a shared DB/queue; an in-task
restart is a group risk for every project — CLAUDE.md §Self-hosting, INFRA.md §P-4).
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): **not
triggered** by this agent (not explicitly instructed; reserved for the Owner per
ORCH-36 / DEPLOY_HOOK.md).
- **Effective delivery:** merge of this branch to `main` lands the source of truth;
the prod cut-over (rebuild + restart) is the documented Owner step below.
## Safe-rollback posture
The reconciler ships with a runtime **kill-switch** independent of any redeploy:
`ORCH_RECONCILE_ENABLED=false` silences the entire sweeper, and
`ORCH_RECONCILE_PLANE_ENABLED=false` disables only the F-2 Plane-poll branch. If the
post-cut-over container is unhealthy, the deploy hook's 60s health loop **auto-rolls back**
to the previous image (snapshotted in `PREV_IMAGE_FILE`).
## Handoff — Owner prod cut-over (DEPLOY_HOOK.md, INFRA.md §Self-hosting)
Perform **only in a quiet window** and in this order:
1. **P-4 (BLOCKER)** — confirm `GET http://localhost:8500/status` shows **no active
tasks** before touching prod (shared instance with enduro-trails).
2. Land the source of truth: merge `feature/ORCH-053-sweeper-webhook-stuck-task``main`
(PR), then host `git pull` on `main` under uid 1000 (`/home/slin/repos/orchestrator`).
3. Prod cut-over via the deploy hook (conscious prod override — defaults are staging):
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
bash scripts/orchestrator-deploy-hook.sh --deploy
```
The hook snapshots the previous image, rebuilds+restarts, runs a 60s health loop on
`:8500/health`, and **auto-rolls back** if the new container is unhealthy.
4. Post-deploy smoke:
- `GET /health` → `200 {"status":"ok"}`.
- `GET /queue` → response carries the new `reconcile` block (interval, grace,
last-pass snapshot).
- Confirm a stuck task is unblocked by the sweeper (or that a synchronous task is
untouched — no spurious notifications), and `docker logs` shows the reconciler
thread started after the worker.
5. Optional staged rollout: set `ORCH_RECONCILE_NOTIFY_UNBLOCK=true` and watch the first
unblock; keep `ORCH_RECONCILE_ENABLED` as the instant kill-switch.
## Summary
| Item | State |
|------|-------|
| Staging gate (`check_staging_status`) | SUCCESS (10/10) |
| Change scope | runtime `src/` (new daemon) → rebuild+restart required |
| DB schema migration | none (existing tables; ADR-0007 invariant) |
| Kill-switch / rollback | `ORCH_RECONCILE_ENABLED` env + deploy-hook auto-rollback |
| In-task prod rebuild/restart | NOT performed (self-hosting safeguard, by design) |
| Prod cut-over | handed off to Owner (P-4 + deploy hook, prod override) |
| Deploy stage verdict | SUCCESS |

View File

@@ -0,0 +1,42 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T20:54:16Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
All checks passed — staging gate is GREEN.
## Run
- **Canonical execution:** inside container `orchestrator-staging` (ORCH-048, ADR-001).
The host environment has no `docker` CLI, so the `docker exec` was driven through the
Docker Engine API over the unix socket `/var/run/docker.sock` — functionally equivalent
to `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`. B6 registry-isolation therefore reads the
running staging instance's own process-env (`.env.staging`), avoiding the false-FAIL of a
host-side run.
- **Mode:** `stub` (early-artifact verification: branch + QG-0 comment; no LLM credits).
- **Container:** `orchestrator-staging` (095be2c4ca3f)
- **Exit code:** 0
## Result: 10/10 checks PASS
| Block | Check | Verdict |
|-------|-------|---------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 (counts/max_concurrency/resilience) | PASS |
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
| B ACCESS | B4 Plane sandbox project accessible | PASS |
| B ACCESS | B5 Gitea orchestrator-sandbox accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
| C E2E | C9a Branch appears in orchestrator-sandbox | PASS |
| C E2E | C9b Analyst job enqueued in staging queue | PASS |
Cleanup completed (sandbox branch + Plane issue + DB rows removed). The `GET /queue`
response exposed the `resilience` block; the ORCH-053 reconciler runs in this staging
instance without destabilising the stand.

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,26 @@
# 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). |
| **R-9** (realized) | Та же петля `deploy-staging → development` по ВТОРОЙ причине: `docker build` staging-образа падает (rc=1), т.к. `Dockerfile` `COPY data/ ./data/` ссылается на gitignore-каталог, отсутствующий в build-context воркти. Всплыло, когда waiver C9a/C9b впервые пропустил конвейер до пересборки образа (`check_staging_image_fresh`, ORCH-058). | — (произошло) | Высокое | `COPY data/ ./data/``RUN mkdir -p /app/data`. `data/` приходит через compose bind-mount, в образ запекать нечего. Инвариант: `Dockerfile` не `COPY` gitignore-путей (иначе сборка из воркти ломается). Гард — `tests/test_dockerfile_worktree_buildable.py`. |
## Контрактные инварианты (не нарушать)
- `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

@@ -9,11 +9,29 @@
# TARGET_IMAGE - image name for retag (default: orchestrator-orchestrator-staging)
# COMPOSE_PROFILE - docker compose profile (default: staging)
# PREV_IMAGE_FILE - path to prev-image snapshot (default: $REPO/.deploy-prev-image-staging)
# SOURCE_IMAGE - build-once source image (default: unset; ORCH-36)
# 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
@@ -25,6 +43,14 @@ TARGET_PORT="${TARGET_PORT:-8501}"
TARGET_IMAGE="${TARGET_IMAGE:-orchestrator-orchestrator-staging}"
COMPOSE_PROFILE="${COMPOSE_PROFILE:-staging}"
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
@@ -122,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)
# ============================================================================
@@ -139,10 +216,38 @@ else
log "No previous image captured (first deploy or service not running?)"
fi
# 2. Pull latest code
# 2. Pull latest code (keeps the host working tree current for future builds;
# the DEPLOYED artefact is the retagged SOURCE_IMAGE below when build-once).
log "git pull origin main"
git pull origin main >> "$LOG" 2>&1
# 2b. Build-once (ORCH-36): retag the prevalidated staging image onto TARGET_IMAGE
# instead of rebuilding, so prod runs the exact artefact that passed staging.
# 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
log "BUILD-ONCE: SOURCE_IMAGE '$SOURCE_IMAGE' not found locally - aborting (exit 1)"
exit 1
fi
fi
# 3. Restart service
log "Starting $TARGET_SERVICE (profile=$COMPOSE_PROFILE)"
if [[ -n "$COMPOSE_PROFILE" ]]; then

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):
@@ -214,7 +241,14 @@ class AgentLauncher:
Same spawn path as launch(), but threads job['id'] through so the monitor
can update the job's status (done / requeue / failed) and link jobs.run_id
to the agent_runs row. Returns the agent_run_id.
ORCH-036: the reserved-agent ``deploy-finalizer`` is a DETERMINISTIC
(no-LLM) job — intercept it BEFORE _spawn (which would raise
"Unknown agent", R-6) and run the deploy finalizer synchronously, driving
the jobs row status itself. Returns None (no agent_run row).
"""
if job.get("agent") == "deploy-finalizer":
return self._run_deploy_finalizer_job(job)
return self._spawn(
job["agent"],
job["repo"],
@@ -223,6 +257,27 @@ class AgentLauncher:
job_id=job["id"],
)
def _run_deploy_finalizer_job(self, job: dict):
"""ORCH-036 Phase C: run the deterministic deploy finalizer for a job.
Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row
done/failed here. Any error is contained (the finalizer never-raises, but
we guard anyway so a finalizer fault can't wedge the worker).
"""
from ..db import mark_job
from .. import stage_engine
try:
stage_engine.run_deploy_finalizer(job)
mark_job(job["id"], "done")
logger.info(f"deploy-finalizer job {job['id']} done")
except Exception as e:
logger.error(f"deploy-finalizer job {job['id']} failed: {e}")
try:
mark_job(job["id"], "failed", error=f"deploy-finalizer 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().
@@ -554,6 +609,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

@@ -130,6 +130,141 @@ class Settings(BaseSettings):
ci_poll_max_attempts: int = 12
ci_poll_interval_s: int = 10
# ORCH-043: merge-gate (auto-rebase + re-test + merge-lock) on the
# deploy-staging -> deploy edge. A deterministic sub-gate (no LLM) that
# catches the up-to-date branch up to the CURRENT origin/main, re-tests it,
# and serialises merges so two green branches can't break main.
# merge_gate_enabled -> global kill-switch; False -> no-op pass for the
# whole gate (staged rollout, env ORCH_MERGE_GATE_ENABLED).
# merge_gate_repos -> CSV of repos where the gate is REAL; empty means
# only the self-hosting repo (orchestrator). Other
# repos -> conditional no-op (mirrors ORCH-35 staging).
# merge_retest_timeout_s -> wall-clock budget for the post-rebase re-test.
# merge_retest_target -> pytest target for the re-test (portability across repos).
# merge_lock_timeout_s -> max lease age; an older lease is reclaimed (crash backstop).
# merge_defer_delay_s -> delay before re-running the gate when the lock is busy.
# merge_defer_max_attempts -> defer retries before escalation (avoids livelock).
merge_gate_enabled: bool = True
merge_gate_repos: str = ""
merge_retest_timeout_s: int = 600
merge_retest_target: str = "tests/"
merge_lock_timeout_s: int = 300
merge_defer_delay_s: int = 60
merge_defer_max_attempts: int = 5
# ORCH-036: executable self-deploy (deploy stage drives the host hook).
# The `deploy` stage for the self-hosting repo is turned into a REAL prod
# restart via a detached host process, gated by a manual approve. Three-phase
# design (ADR-001): A=approve-request, B=initiate (human Approved), C=finalizer
# maps the hook exit-code -> deploy_status. Non-self repos are unaffected.
#
# self_deploy_enabled -> global kill-switch; False -> no Phase A/B/C
# interception (the legacy synchronous deployer
# path runs for everyone, env ORCH_SELF_DEPLOY_ENABLED).
# self_deploy_repos -> CSV of repos where executable self-deploy is
# REAL; empty -> only the self-hosting repo
# (orchestrator). Mirrors merge_gate_repos.
# deploy_require_manual_approve -> require a human Approved before the prod
# restart (BR-5). Default true; NOT toggled in
# ORCH-36 (AC-12). false -> Phase A initiates
# immediately (structural branch, off by default).
# deploy_finalize_delay_s -> delay before the first finalize poll; must be
# > the hook health-loop (~60s) so the verdict
# usually exists on the first poll.
# deploy_finalize_max_attempts -> bounded finalize-defer budget (anti-livelock).
# ssh / hook target (detached prod restart; real values live on the host):
# deploy_ssh_user / deploy_ssh_host -> ssh target for the host hook (INFRA P-2).
# deploy_hook_script -> path to the hook ON THE HOST (relative to repo).
# deploy_host_repo_path -> orchestrator clone path on the host.
# prod overrides passed to the hook for build-once (retag staging image -> prod):
# deploy_prod_source_image -> image validated on staging (retagged, no rebuild).
# deploy_prod_target_service / _port / _image / _compose_profile -> prod profile.
# deploy_prod_prev_image_file -> prod prev-image snapshot (separate from staging).
self_deploy_enabled: bool = True
self_deploy_repos: str = ""
deploy_require_manual_approve: bool = True
deploy_finalize_delay_s: int = 90
deploy_finalize_max_attempts: int = 10
deploy_ssh_user: str = "slin"
deploy_ssh_host: str = ""
deploy_hook_script: str = "scripts/orchestrator-deploy-hook.sh"
deploy_host_repo_path: str = "/home/slin/repos/orchestrator"
deploy_prod_source_image: str = "orchestrator-orchestrator-staging"
deploy_prod_target_service: str = "orchestrator"
deploy_prod_target_port: int = 8500
deploy_prod_target_image: str = "orchestrator-orchestrator"
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
# Telegram notifications
telegram_bot_token: str = ""
telegram_chat_id: str = ""

118
src/db.py
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()
@@ -324,19 +417,34 @@ def enqueue_job(
task_content: str | None = None,
task_id: int | None = None,
max_attempts: int = 2,
available_at_delay_s: int | None = None,
) -> int:
"""Enqueue a new job (status='queued'). Returns the new job id.
This is what webhook handlers call instead of launching an agent in-process:
it is a fast DB INSERT that returns immediately. The background worker
(queue_worker) picks the job up later.
ORCH-043 (merge-gate defer): when ``available_at_delay_s`` is given the job's
``available_at`` is set to ``now + delay`` so claim_next_job won't pick it up
until the delay elapses (re-uses the existing ORCH-1 backoff gate). Used to
re-queue the staging-deployer after a "merge-lock busy" defer without burning a
worker slot in a blocking wait.
"""
conn = get_db()
cursor = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts) "
"VALUES (?, ?, ?, ?, ?)",
(agent, repo, task_id, task_content, max_attempts),
)
if available_at_delay_s is not None:
cursor = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts, available_at) "
"VALUES (?, ?, ?, ?, ?, datetime('now', ?))",
(agent, repo, task_id, task_content, max_attempts,
f"+{int(available_at_delay_s)} seconds"),
)
else:
cursor = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts) "
"VALUES (?, ?, ?, ?, ?)",
(agent, repo, task_id, task_content, max_attempts),
)
job_id = cursor.lastrowid
conn.commit()
conn.close()

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,12 @@ 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
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
"poll_interval": worker.poll_interval,
"resilience": worker.status(),
"reconcile": reconciler.status(),
"recent": recent_jobs(10),
}

340
src/merge_gate.py Normal file
View File

@@ -0,0 +1,340 @@
"""Merge-gate core (ORCH-043): catch a branch up to the CURRENT origin/main,
re-test it, and serialise merges with a file lease.
Background
----------
The pipeline validates a branch against the ``main`` it was BRANCHED from, not the
``main`` at the moment of merge. Between "branch validated" and "branch merged" a
parallel task may have advanced ``main`` -> a *semantic* merge conflict: git merges
with no textual conflict, yet the combined ``main`` is broken. For the self-hosting
``orchestrator`` repo that means a red ``main`` of the tool serving every project.
This module provides the deterministic (no-LLM) primitives the quality-gate
``check_branch_mergeable`` (src/qg/checks.py) composes on the
``deploy-staging -> deploy`` edge, BEFORE the deployer merges the PR:
* ``branch_is_behind_main`` -> is the branch missing the latest origin/main?
* ``auto_rebase_onto_main`` -> rebase onto origin/main + push --force-with-lease
(ONLY the task branch; NEVER main).
* ``retest_branch`` -> run the project test-suite in the caught-up worktree.
* file lease (``acquire_merge_lease`` / ``release_merge_lease``) -> serialise the
"catch-up + re-test + merge" of ONE repo, held from the gate to the actual merge.
Invariants (self-hosting safety, ТЗ §10):
* NEVER push or force-push ``main`` — the only force op is ``--force-with-lease``
on the task branch.
* All git ops run in the per-branch worktree (ensure_worktree), never the shared clone.
* Every public function honours a strict **never-raise** contract: any git/OS error
-> ``(False, "<reason>")`` (or a safe bool), never a propagated exception.
"""
import json
import logging
import os
import subprocess
import time
from .config import settings
from .git_worktree import ensure_worktree, get_worktree_path
logger = logging.getLogger("orchestrator.merge_gate")
# git sub-command timeouts (seconds). Generous but bounded so a hung git never
# wedges the monitor-thread that runs the gate.
_FETCH_TIMEOUT = 60
_REBASE_TIMEOUT = 120
_PUSH_TIMEOUT = 60
_SHORT_TIMEOUT = 30
# ---------------------------------------------------------------------------
# behind / ancestor detection
# ---------------------------------------------------------------------------
def branch_is_behind_main(repo: str, branch: str) -> bool:
"""Return True iff ``branch`` does NOT already contain the latest origin/main.
A branch is "behind" when ``origin/main`` is **not** an ancestor of the branch
HEAD (``git merge-base --is-ancestor origin/main HEAD`` returns non-zero). All
work happens in the per-branch worktree (ORCH-2 / S-4 isolation).
Never-raise (AC-9 / TC-03): any git/OS failure or an ambiguous result is treated
as "cannot prove the branch is up-to-date" -> return True (force a rebase attempt
rather than merge blindly). It returns a bool, never raises.
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("branch_is_behind_main: worktree error for %s/%s: %s", repo, branch, e)
return True
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_FETCH_TIMEOUT,
)
r = subprocess.run(
["git", "-C", wt, "merge-base", "--is-ancestor", "origin/main", "HEAD"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("branch_is_behind_main: git error for %s/%s: %s", repo, branch, e)
return True
if r.returncode == 0:
# origin/main IS an ancestor of HEAD -> branch already up-to-date.
return False
if r.returncode == 1:
# origin/main is NOT an ancestor -> branch is behind.
return True
# Any other code (e.g. bad ref) -> ambiguous; do not merge blindly.
logger.warning(
"branch_is_behind_main: ambiguous merge-base rc=%s for %s/%s (treating as behind)",
r.returncode, repo, branch,
)
return True
def _conflicted_files(wt: str) -> str:
"""Best-effort list of unmerged (conflicting) files in the worktree."""
try:
r = subprocess.run(
["git", "-C", wt, "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, timeout=_SHORT_TIMEOUT,
)
files = r.stdout.strip().replace("\n", ", ")
return files or "unknown"
except (subprocess.SubprocessError, OSError):
return "unknown"
# ---------------------------------------------------------------------------
# auto-rebase onto origin/main
# ---------------------------------------------------------------------------
def auto_rebase_onto_main(repo: str, branch: str) -> tuple[bool, str]:
"""Catch ``branch`` up to ``origin/main`` via rebase, then push it.
Steps (all in the per-branch worktree):
1. ``git fetch origin main``.
2. ``git rebase origin/main``:
- textual conflict (non-zero) -> ``git rebase --abort`` (leave worktree
clean) -> ``(False, "rebase conflict: <files>")`` (AC-3).
3. clean rebase -> ``git push --force-with-lease origin <branch>`` — ONLY the
task branch, NEVER ``main`` (AC-7) -> ``(True, "rebased onto origin/main")``.
Never-raise (AC-9): any git/OS error -> ``(False, "<reason>")``.
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
return False, f"rebase setup error: {e}"
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_FETCH_TIMEOUT,
)
r = subprocess.run(
["git", "-C", wt, "rebase", "origin/main"],
capture_output=True, text=True, timeout=_REBASE_TIMEOUT,
)
if r.returncode != 0:
files = _conflicted_files(wt)
subprocess.run(
["git", "-C", wt, "rebase", "--abort"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
logger.warning("auto_rebase: conflict on %s/%s: %s", repo, branch, files)
return False, f"rebase conflict: {files}"
# Clean rebase -> push ONLY the task branch with a lease (never main).
p = subprocess.run(
["git", "-C", wt, "push", "--force-with-lease", "origin", branch],
capture_output=True, text=True, timeout=_PUSH_TIMEOUT,
)
if p.returncode != 0:
detail = (p.stderr or p.stdout or "").strip()[:200]
logger.warning("auto_rebase: push failed on %s/%s: %s", repo, branch, detail)
return False, f"push --force-with-lease failed: {detail}"
logger.info("auto_rebase: %s/%s rebased onto origin/main and pushed", repo, branch)
return True, "rebased onto origin/main"
except subprocess.TimeoutExpired:
# Leave no half-finished rebase behind.
try:
subprocess.run(
["git", "-C", wt, "rebase", "--abort"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError):
pass
return False, "rebase timeout"
except (subprocess.SubprocessError, OSError) as e:
return False, f"rebase error: {e}"
# ---------------------------------------------------------------------------
# re-test in the caught-up worktree
# ---------------------------------------------------------------------------
def retest_branch(repo: str, branch: str) -> tuple[bool, str]:
"""Run the project test-suite in the (already caught-up) branch worktree.
Command: ``python -m pytest <merge_retest_target>`` (default ``tests/``),
matching the orchestrator CI / check_tests_local pattern. Bounded by
``settings.merge_retest_timeout_s``.
Returns:
* ``(True, "re-test green")`` — pytest rc == 0
* ``(False, "re-test timeout after <T>s")`` — exceeded the timeout (AC-6)
* ``(False, "re-test failed: ...<tail>")`` — non-zero rc, with output tail
Never-raise (AC-9): any setup/OS error -> ``(False, "<reason>")``.
"""
wt = get_worktree_path(repo, branch)
if not os.path.isdir(wt):
# Caller usually rebased first (worktree exists); ensure as a fallback.
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract
return False, f"re-test setup error: {e}"
target = settings.merge_retest_target or "tests/"
timeout = settings.merge_retest_timeout_s
try:
r = subprocess.run(
["python", "-m", "pytest", target, "-q"],
cwd=wt, capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
logger.warning("retest_branch: timeout (%ss) on %s/%s", timeout, repo, branch)
return False, f"re-test timeout after {timeout}s"
except (subprocess.SubprocessError, OSError) as e:
return False, f"re-test error: {e}"
if r.returncode == 0:
return True, "re-test green"
tail = ((r.stdout or "") + (r.stderr or ""))[-500:]
logger.warning("retest_branch: red on %s/%s", repo, branch)
return False, f"re-test failed: ...{tail}"
# ---------------------------------------------------------------------------
# merge-lease (serialise catch-up + re-test + merge per repo)
# ---------------------------------------------------------------------------
def _lease_path(repo: str) -> str:
"""Filesystem path of the per-repo merge lease (no schema change, ТЗ §4)."""
return os.path.join(settings.repos_dir, f".merge-lease-{repo}.json")
def _read_lease(path: str) -> dict | None:
"""Read+parse the lease file; None if missing or corrupt (never-raise)."""
try:
with open(path, "r", encoding="utf-8") as f:
return json.loads(f.read())
except FileNotFoundError:
return None
except (OSError, ValueError) as e:
logger.warning("merge-lease read error at %s: %s", path, e)
return None
def _write_lease(path: str, holder: dict) -> None:
"""Atomically (O_CREAT|O_EXCL) write the lease; raises FileExistsError if held."""
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
os.write(fd, json.dumps(holder).encode("utf-8"))
finally:
os.close(fd)
def acquire_merge_lease(
repo: str, branch: str, work_item_id: str | None = None, task_id: int | None = None
) -> tuple[bool, str]:
"""Try to acquire the per-repo merge lease. **Non-blocking** (anti-deadlock).
Holder identity is the task ``branch`` (stable, one branch per task). Outcomes:
* no lease file -> acquire, write metadata -> ``(True, "lease acquired")``
* lease held by self -> idempotent re-acquire (restart/retry) -> ``(True, "lease already held")``
* lease held by other, age < merge_lock_timeout_s -> ``(False, "merge-lock busy")``
* lease held by other, age >= merge_lock_timeout_s -> stale -> reclaim with a
``logger.warning`` (the holder process died without releasing) -> ``(True, ...)``
Never-raise: any unexpected error -> ``(False, "merge-lock busy")`` so the caller
DEFERS and retries rather than burning a developer retry on an infra hiccup.
"""
path = _lease_path(repo)
holder = {
"branch": branch,
"work_item_id": work_item_id,
"task_id": task_id,
"acquired_at": time.time(),
"pid": os.getpid(),
}
try:
try:
_write_lease(path, holder)
logger.info("merge-lease acquired for %s by %s", repo, branch)
return True, "lease acquired"
except FileExistsError:
pass
existing = _read_lease(path)
if existing is None:
# Corrupt/empty lease file — reclaim it.
_force_write_lease(path, holder)
logger.warning("merge-lease for %s was corrupt; reclaimed by %s", repo, branch)
return True, "lease reclaimed (corrupt)"
if existing.get("branch") == branch:
return True, "lease already held"
age = time.time() - float(existing.get("acquired_at") or 0)
if age >= settings.merge_lock_timeout_s:
_force_write_lease(path, holder)
logger.warning(
"merge-lease for %s was stale (age %.0fs >= %ss, holder=%s); reclaimed by %s",
repo, age, settings.merge_lock_timeout_s, existing.get("branch"), branch,
)
return True, "lease reclaimed (stale)"
logger.info(
"merge-lease for %s busy (held by %s, age %.0fs); %s defers",
repo, existing.get("branch"), age, branch,
)
return False, "merge-lock busy"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("acquire_merge_lease unexpected error for %s/%s: %s", repo, branch, e)
return False, "merge-lock busy"
def _force_write_lease(path: str, holder: dict) -> None:
"""Overwrite the lease (used for stale/corrupt reclaim). Best-effort."""
try:
with open(path, "w", encoding="utf-8") as f:
f.write(json.dumps(holder))
except OSError as e:
logger.warning("merge-lease force-write error at %s: %s", path, e)
def release_merge_lease(repo: str, branch: str | None = None) -> None:
"""Release the per-repo merge lease. **Idempotent** and **holder-aware**.
If ``branch`` is given, the lease is removed ONLY when the current holder's
branch matches (so a delayed release from an already-merged task can never
delete a lease a DIFFERENT task acquired afterwards). With ``branch=None`` the
release is unconditional (best-effort backstop). Never raises.
"""
path = _lease_path(repo)
try:
if branch is not None:
existing = _read_lease(path)
if existing is not None and existing.get("branch") != branch:
logger.info(
"merge-lease release skipped for %s: holder=%s != %s",
repo, existing.get("branch"), branch,
)
return
os.remove(path)
logger.info("merge-lease released for %s (%s)", repo, branch or "force")
except FileNotFoundError:
return
except OSError as e:
logger.warning("merge-lease release error for %s: %s", repo, e)

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