Compare commits

...

86 Commits

Author SHA1 Message Date
a8221f01c8 docs(history): LESSONS_ORCH-048 — staging B6 isolation, variant (v), chicken-egg lesson
All checks were successful
CI / test (pull_request) Successful in 12s
2026-06-06 10:30:50 +03:00
2a36ed80b9 fix(staging_check): ORCH-048 B6 reads registry inside staging container (variant v)
ADR-001 in-container run; removes host-path hack; _evaluate_b6 pure fn; deployer.md+STAGING_CHECK.md updated. Staging 10/10 PASS incl B6.
2026-06-06 10:24:10 +03:00
3f1f3fc73b Merge pull request 'docs(ORCH-048): prod deploy log — SUCCESS (bind-mount-only, prod untouched)' (#48) from deploy-log/ORCH-048-20260606T071157 into main 2026-06-06 10:12:28 +03:00
8a70398496 docs(ORCH-048): prod deploy log — SUCCESS (bind-mount-only, prod untouched, staging gate green)
All checks were successful
CI / test (pull_request) Successful in 14s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 07:11:57 +00:00
9c1c028dc1 Merge pull request 'docs(ORCH-048): staging gate log — SUCCESS (10/10, B6 registry isolation PASS)' (#47) from staging-log/ORCH-048-20260606T071003 into main 2026-06-06 10:10:18 +03:00
81e6ec5a20 docs(ORCH-048): staging gate log — SUCCESS (10/10, B6 registry isolation PASS)
All checks were successful
CI / test (pull_request) Successful in 13s
Staging suite run inside orchestrator-staging via docker exec (canonical,
ADR-001). All 10/10 checks pass, exit 0. B6 now reads registry from the
running staging instance's own process-env -> sandbox present, prod ET/ORCH
absent, no false FAIL / spurious rollback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 07:10:03 +00:00
913c185232 tester(ET): auto-commit from tester run_id=154
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s
2026-06-06 07:07:53 +00:00
2424f9aaad reviewer(ET): auto-commit from reviewer run_id=153
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 14s
2026-06-06 07:05:47 +00:00
28d019a1e2 fix(staging_check): B6 reads registry from running staging instance env
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 16s
B6 false-FAILed because it built the project registry from the
launcher process-env via a host-path hack (sys.path.insert +
importlib.reload), not from the running staging instance. Run from the
host, ORCH_PROJECTS_JSON is unset -> default ET+ORCH registry -> false
FAIL -> spurious deploy-staging -> development rollback.

Variant (v) per ADR-001: remove the host-path hack; canonically run the
suite INSIDE orchestrator-staging via docker exec so src.projects
resolves from /app (PYTHONPATH) with .env.staging. Verdict logic
extracted into pure _evaluate_b6(known) -> (passed, detail) +
_known_project_ids_from_registry() / _run_b6() with deterministic FAIL on
source unavailability. deployer.md and STAGING_CHECK.md updated to the
docker exec command. src/projects.py, .env* and checks A/B4/B5/C
untouched.

Refs: ORCH-048

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 07:03:31 +00:00
d6744c3c05 architect(ET): auto-commit from architect run_id=151
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 14s
2026-06-06 06:59:56 +00:00
stream
7a6c7a0151 docs(ORCH-048): owner decision — pin variant (v), reject (a) HTTP-endpoint (chicken-egg)
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s
2026-06-06 06:56:09 +00:00
04e88b833f Merge pull request 'docs(ORCH-048): staging gate log — FAILED (9/10, B6 /projects 404 on stale staging)' (#46) from staging-log/ORCH-048-20260606T053413 into main 2026-06-06 08:34:44 +03:00
7203812b17 docs(ORCH-048): staging gate log — FAILED (9/10, B6 /projects 404 on stale staging)
All checks were successful
CI / test (pull_request) Successful in 12s
Staging instance (8501) still runs a pre-ORCH-048 image without GET /projects,
so B6 deterministically FAILs (endpoint unavailable → no false PASS). Branch
code is correct; remediation is a host-side `--profile staging up -d --build`
of orchestrator-staging before re-running the gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 05:34:14 +00:00
8b5b1f0056 analyst(ET): auto-commit from analyst run_id=145
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 13s
2026-06-06 05:06:33 +00:00
9538103eff docs: init ORCH-048 business request
All checks were successful
CI / test (push) Successful in 13s
2026-06-06 08:03:16 +03:00
0bc2398462 feat(stage_engine): ORCH-046 embed reviewer/tester findings in task_desc (#43)
Some checks failed
CI / test (push) Has been cancelled
Manual merge (Slava trust, variant A). Findings text embedded into developer task_desc (not just link). New src/review_parse.py, graceful fallback. 50 tests pass. Reviewer APPROVED, CI green. Staging FAIL = B6/ORCH-48 (infra, unrelated).
2026-06-06 07:54:03 +03:00
13b7df06b1 deployer(ET): auto-commit from deployer run_id=144
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-06 04:49:37 +00:00
b5f4eb6f2f Merge pull request 'docs(ORCH-046): staging gate log — FAILED (9/10, B6 registry isolation)' (#44) from staging-log/ORCH-046-20260606T044841 into main 2026-06-06 07:49:22 +03:00
75c2b814d8 docs(ORCH-046): staging gate log — FAILED (9/10, B6 registry isolation)
All checks were successful
CI / test (pull_request) Successful in 12s
Staging suite ran end-to-end against staging (8501, stub mode): 9/10 PASS,
exit 1. Failure is B6 — staging project registry not isolated (sees prod
ET/ORCH, sandbox absent), violating the INFRA isolation invariant. Gate is
authoritative and red → staging_status: FAILED (rollback to development).
Note: this is a staging .env/ORCH_PROJECTS_JSON misconfig, not an ORCH-046
code regression (same B6 as ORCH-047).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 04:48:47 +00:00
be10becae2 tester(ET): auto-commit from tester run_id=143
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 12s
2026-06-06 04:46:28 +00:00
4cd55063b4 reviewer(ET): auto-commit from reviewer run_id=142
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-06-06 04:44:57 +00:00
03c3d77cac feat(stage-engine): embed verbatim reviewer/tester findings in rollback task_desc
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
При заворотах на development task_desc теперь несёт дословный must-fix текст
(P0/P1 ревьюера, причина FAIL тестера) вместо одной ссылки на файл — developer-
агент видит суть претензий сразу и не повторяет ту же ошибку, экономя retry-
бюджет и токены общего инстанса.

- Новый defensive-модуль src/review_parse.py (never-raise): extract_review_findings
  (P0/P1 из 12-review.md ## Findings), extract_test_failures (фрагмент тела
  13-test-report.md: pytest output / FAIL-строки / Итог), усечение по лимиту.
- Две rollback-ветки stage_engine: встраивают текст + сохраняют ссылку на полный
  файл; graceful-фоллбэк на ссылку-строку при битом/пустом артефакте.
- Последовательность отката, retry-счётчик, поля AdvanceResult, реестр QG_CHECKS
  не менялись.
- Доки: README (Stage Engine / Откаты), CHANGELOG.
- Тесты: tests/test_review_parse.py, test_stage_engine.py::TestRollbackTaskDescEmbedding.

Refs: ORCH-046

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 04:42:11 +00:00
29e83341b5 architect(ET): auto-commit from architect run_id=140
All checks were successful
CI / test (push) Successful in 11s
2026-06-06 04:36:40 +00:00
c7bca51d4b analyst(ET): auto-commit from analyst run_id=139
All checks were successful
CI / test (push) Successful in 12s
2026-06-06 04:09:41 +00:00
50a3c60b0e docs: init ORCH-046 business request
All checks were successful
CI / test (push) Successful in 13s
2026-06-06 07:06:44 +03:00
615a778d20 docs: lessons 2026-06-05 (#42)
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 00:42:14 +03:00
stream
58116f93bd docs(history): сводные уроки вечера 05.06 — ORCH-17/45/47, деплой прода, грабли auth/git/Gitea
All checks were successful
CI / test (pull_request) Successful in 11s
2026-06-05 21:41:52 +00:00
941eec248e Merge pull request 'docs(ORCH-047): staging gate log — FAILED' (#41) from staging-log/ORCH-047-20260605213215 into main 2026-06-06 00:32:38 +03:00
b061354a8f docs(ORCH-047): staging gate log — FAILED (9/10, B6 registry isolation)
All checks were successful
CI / test (pull_request) Successful in 11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 21:32:15 +00:00
5d04de9eb6 fix(qg): ORCH-047 read result in tests gate (#40)
Manual merge by Owner. check_tests_passed reads result as equal-rank field. APPROVED reviewer v3, 68 tests pass.
2026-06-06 00:25:40 +03:00
edff0484c9 reviewer(ET): auto-commit from reviewer run_id=134
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-06-05 21:18:33 +00:00
2f396452e8 tester(ET): auto-commit from tester run_id=132
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 12s
2026-06-05 21:14:05 +00:00
185eb3f6cf reviewer(ET): auto-commit from reviewer run_id=131
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-05 21:12:23 +00:00
58fc0a8b94 tester(ET): auto-commit from tester run_id=129
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-05 21:07:30 +00:00
c1abfb7436 reviewer(ET): auto-commit from reviewer run_id=128
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-05 21:06:01 +00:00
51a76e8169 fix(qg): read result: alongside verdict:/status: in tests gate
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
_parse_tests_verdict now accepts three equal-rank machine-readable
frontmatter fields in 13-test-report.md — result: (canonical tester
output), verdict: and status: (legacy/enduro-trails). Any one non-empty
field suffices; a negative token in any field stays authoritative.

Fixes the producer/consumer contract mismatch where the tester emits
`result: PASS` (per .openclaw/agents/tester.md) but the gate only read
verdict:/status:, causing a testing->development rollback loop until
MAX_DEVELOPER_RETRIES (observed on ORCH-17). Token sets frozen and gate
signature/QG_CHECKS unchanged for full backward compatibility.

Refs: ORCH-047
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 21:03:32 +00:00
75fb4069a4 architect(ET): auto-commit from architect run_id=126
All checks were successful
CI / test (push) Successful in 11s
2026-06-05 21:00:57 +00:00
c3879f2b80 analyst(ET): auto-commit from analyst run_id=125
All checks were successful
CI / test (push) Successful in 12s
2026-06-05 20:44:58 +00:00
974d4f94db docs: init ORCH-047 business request
All checks were successful
CI / test (push) Successful in 13s
2026-06-05 23:42:29 +03:00
982698c4e3 feat(qg): ORCH-045 CI poll-retry (#39)
Some checks failed
CI / test (push) Has been cancelled
Manual merge by Owner (Slava). check_ci_green polling with retry to fix CI race.
2026-06-05 23:08:15 +03:00
stream
0eff781d13 feat(qg): ORCH-045 — poll check_ci_green with retry to fix CI race (pending->success)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-05 19:59:06 +00:00
b9c61fc1f1 Merge pull request 'docs: uroki ORCH-017 (deadlock shared-gate, isporchennyy telefon, otkat)' (#38) from docs/lessons-orch-017 into main 2026-06-05 22:50:17 +03:00
stream
53c76cb539 docs(history): уроки ORCH-017 — дедлок shared-гейта, испорченный телефон, неполный откат
All checks were successful
CI / test (pull_request) Successful in 13s
2026-06-05 19:49:50 +00:00
26c6f2676f ORCH-017: Telegram approve-ping links to BRD & Plane (#37)
Manual merge by Owner (Слава). Только ссылки в уведомлениях; правка shared gate вынесена в ORCH-47.
2026-06-05 22:45:11 +03:00
stream
43ef160f40 docs(ORCH-017): drop check_tests_passed/result gate notes (belong to ORCH-47); keep only approve-ping link docs
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-05 19:35:28 +00:00
9c28431167 reviewer(ET): auto-commit from reviewer run_id=124
All checks were successful
CI / test (push) Successful in 15s
CI / test (pull_request) Successful in 13s
2026-06-05 19:32:33 +00:00
stream
d615747d53 revert(ORCH-017): drop shared check_tests_passed gate change — moved to ORCH-47 (own ADR); keep only approve-ping links
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-06-05 19:28:27 +00:00
c91eb7f82b reviewer(ET): auto-commit from reviewer run_id=123
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-06-05 18:40:52 +00:00
e62d51aa77 fix(qg): testing gate reads documented tester result: frontmatter key (ORCH-017)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
check_tests_passed/_parse_tests_verdict gated the testing -> deploy-staging
transition on `verdict:`/`status:` in 13-test-report.md, but the tester agent
prompt (.openclaw/agents/tester*) documents `result: PASS | FAIL` as THE
machine-readable field. A report that followed the contract literally
(ORCH-017: only `result: PASS`, no verdict:/status:) was bounced back to
development with a misleading "Tests FAILED". ORCH-016 only passed because its
report redundantly carried both `verdict:` and `result:`.

Treat `result:` as a first-class machine field alongside verdict/status; a
negative token in any field stays authoritative (ET-013 contract preserved).
Self-hosting QG fix: unblocks every project whose tester emits only `result:`.

Docs updated in-PR: CHANGELOG, architecture README machine-keys note.
Tests: test_qg.py::TestCheckTestsPassed::test_result_pass_only_passes / _fail_only_fails.

Refs: ORCH-017
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:34:25 +00:00
0e999d289d reviewer(ET): auto-commit from reviewer run_id=120
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-06-05 18:25:02 +00:00
950a86e4d8 tester(ET): auto-commit from tester run_id=118
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 17s
2026-06-05 18:20:16 +00:00
6509891f74 reviewer(ET): auto-commit from reviewer run_id=117
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 11s
2026-06-05 18:18:42 +00:00
69a4aaab99 feat(notifications): direct BRD + Plane links in approve ping (ORCH-017)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
notify_approve_requested now embeds two HTML <a> links into the single
notifying approve-gate message: a Gitea branch-view link to 01-brd.md and a
Plane issue browser link. Adds ORCH_PLANE_WEB_URL (external Plane web URL,
fallback to plane_api_url) with a loopback-guard that omits the Plane link
when the resolved base is localhost/empty (no broken localhost URLs in prod).

Each link is built independently and omitted on missing data; the message and
the "flip to Approved" call to action are always sent as exactly one ping. The
shared send_telegram helper is left untouched (min blast radius for the
self-hosting prod container). Dynamic labels are html.escaped; parse_mode=HTML
preserved. QG registry / stages / approve handler unchanged.

Docs updated in-PR: CHANGELOG, .env.example, INFRA env map.
Tests: test_notify_approve_links.py, test_analysis_approve_flow_links.py.

Refs: ORCH-017
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:58:00 +00:00
c9b1195c0b architect(ET): auto-commit from architect run_id=115
All checks were successful
CI / test (push) Successful in 12s
2026-06-05 17:50:28 +00:00
08528b655e analyst(ET): auto-commit from analyst run_id=112
All checks were successful
CI / test (push) Successful in 12s
2026-06-05 17:39:34 +00:00
7f31d62a4d docs: init ORCH-017 business request
All checks were successful
CI / test (push) Successful in 13s
2026-06-05 19:59:55 +03:00
401bf66fe0 feat(agents): configurable LLM model + effort per-agent and per-project (ORCH-41) (#36)
Some checks failed
CI / test (push) Has been cancelled
2026-06-05 19:45:19 +03:00
8da571de86 feat(plane): unified status-comment format with duration line (ORCH-016) (#34) 2026-06-05 17:50:47 +03:00
f375be249f fix(tests): per-project Plane states in webhook tests + close CI hole (ORCH-39) (#35) 2026-06-05 17:36:40 +03:00
053ea3b1c5 docs(ORCH-016): merge staging-log into main (staging_status: SUCCESS)
Mirrors the deploy-log pattern: deployer writes 15-staging-log.md on the
feature branch, then merges the artifact into origin/main so the
check_staging_status quality gate can read it via _staging_log_from_main()
(see src/qg/checks.py:489).

Verdict from the staging run on http://localhost:8501 (mode=stub):
  staging_status: SUCCESS  (10/10 checks PASS)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 12:49:59 +00:00
a2cf1454fd Merge pull request 'fix(plane): resolve issue states per-project instead of hardcoded enduro UUIDs (ORCH-10)' (#33) from feature/ORCH-10-per-project-states into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-05 14:42:56 +03:00
Dev Agent
00325bcab0 fix(plane): resolve issue states per-project instead of hardcoded enduro UUIDs (ORCH-10)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 10s
ORCH-10 root cause: PLANE_STATES was a global dict hardcoding enduro-trails
UUIDs. The webhook comparison  only
matched ET UUID (b873d9eb) and silently ignored the ORCH in_progress UUID
(e331bfb3), blocking pipeline start for all orchestrator-project tasks.

Changes:
- src/plane_sync.py:
  * Rename PLANE_STATES -> _DEFAULT_STATES (enduro UUIDs kept as safe fallback).
  * PLANE_STATES preserved as alias to _DEFAULT_STATES (backward compat).
  * Add get_project_states(project_id) -> {logical_key: state_uuid}:
    fetches Plane API GET /projects/<id>/states/, maps by state name,
    caches per project_id, falls back to _DEFAULT_STATES on API failure.
  * Add _STATES_CACHE: dict, reload_project_states(project_id=None).
  * Add _PLANE_NAME_TO_KEY mapping and _STAGE_TO_STATE_KEY for clean lookup.
  * Add stage_to_state(stage, project_id) using get_project_states().
  * update_issue_state() uses stage_to_state() instead of STAGE_TO_STATE dict.
  * set_issue_{needs_input,in_review,blocked,done,in_progress,stage_state}()
    all resolve state UUID via get_project_states(project_id) instead of
    the global PLANE_STATES dict.

- src/webhooks/plane.py:
  * handle_issue_updated: import get_project_states, resolve proj_states per
    incoming project_id, compare new_state against proj_states["in_progress"],
    proj_states["approved"], proj_states["rejected"].
  * start_pipeline QG-0 blocked path: use get_project_states(plane_project_id)
    instead of PLANE_STATES["blocked"].

- tests/test_orch10_states.py: 23 new tests covering:
  * get_project_states returns correct UUIDs for both ET and ORCH projects.
  * API failure / empty response / None project_id -> _DEFAULT_STATES fallback.
  * Caching and reload_project_states (per-project and full flush).
  * stage_to_state() per-project resolution.
  * Webhook in_progress triggers pipeline for BOTH b873d9eb (ET) and e331bfb3 (ORCH).
  * Webhook approved/rejected routes correctly per project.
  * PLANE_STATES alias and _DEFAULT_STATES backward compat.
2026-06-05 14:23:31 +03:00
5ecd1c4692 Merge pull request 'docs(orchestrator): doc canon + CLAUDE.md + agent prompts + reviewer-gate (self-hosting)' (#32) from docs/ORCH-9-canon into main 2026-06-05 13:28:50 +03:00
Dev Agent
7c68d1d812 docs(orchestrator): adopt enduro doc canon + CLAUDE.md + ADR (ORCH-9)
All checks were successful
CI / test (pull_request) Successful in 9s
2026-06-05 12:33:55 +03:00
f1b31463ad Merge pull request 'feat(pipeline): add deploy-staging gate before prod deploy (ORCH-35)' (#31) from feature/ORCH-35-staging-gate into main 2026-06-05 10:43:38 +03:00
Dev Agent
e0c14fae5f fix(pipeline): make deploy-staging gate conditional on self-hosting repo (ORCH-35)
All checks were successful
CI / test (push) Successful in 10s
CI / test (pull_request) Successful in 10s
2026-06-05 10:36:46 +03:00
Dev Agent
e0b6e92b09 feat(pipeline): add deploy-staging gate before prod deploy (ORCH-35)
All checks were successful
CI / test (push) Successful in 9s
CI / test (pull_request) Successful in 9s
2026-06-05 10:06:06 +03:00
e405a55f9d Merge pull request 'feat(staging): add orchestrator deploy hook with health-check and auto-rollback (ORCH-34)' (#30) from feature/ORCH-34-deploy-hook into main 2026-06-05 09:46:18 +03:00
Dev Agent
a6cbacb62c feat(staging): add orchestrator deploy hook with health-check and auto-rollback (ORCH-34)
All checks were successful
CI / test (push) Successful in 13s
CI / test (pull_request) Successful in 9s
2026-06-05 09:26:12 +03:00
93169f16e0 Merge pull request 'feat(staging): add live staging check suite (smoke + access + e2e) [ORCH-33]' (#29) from feature/ORCH-33-staging-testsuite into main 2026-06-05 09:12:51 +03:00
Dev Agent
94334bdd42 feat(staging): add live staging check suite (smoke + access + e2e)
All checks were successful
CI / test (push) Successful in 10s
CI / test (pull_request) Successful in 10s
2026-06-05 08:54:56 +03:00
3b68a29ae1 Merge PR #28: add isolated orchestrator-staging service (ORCH-31)
Stage 1/5 of staging environment for self-hosting (ORCH-7).
Adds orchestrator-staging compose service under staging profile, isolated DB, .env.staging.example, docs. Prod untouched; service inert until explicitly started.
2026-06-05 08:01:10 +03:00
Dev Agent
6c1e5fff52 feat(staging): add isolated orchestrator-staging service (port 8501, separate DB)
All checks were successful
CI / test (push) Successful in 10s
CI / test (pull_request) Successful in 9s
- Add orchestrator-staging compose service under profile 'staging'
  so normal 'docker compose up -d' does NOT start it.
- Port 8501 via command override; network_mode: host (no ports mapping needed).
- DB isolation via separate volume ./data/staging:/app/data — physically
  separate from prod ./data/orchestrator.db on the host.
- ORCH_DB_PATH=/app/data/orchestrator.db explicit in env (same container
  path, isolated by volume mount).
- Add .env.staging.example with all required keys and placeholders.
- Update .gitignore: add .env.staging and data/staging/ exclusions.
- Add docs/STAGING.md: how to start staging, architecture table, roadmap.

Refs: ORCH-31 (Stage 1 of 5)
2026-06-05 07:34:48 +03:00
d0a34249cc Merge PR #27: isolate webhook tests + add CI workflow (self-hosting gate)
Closes the CI quality gate for orchestrator self-hosting (ORCH-7).
Full pytest tests/ green (294 passed). Supersedes #26.
2026-06-05 07:29:04 +03:00
Dev Agent
1baae81165 test: reset webhook secret per-test to fix cross-file isolation (CI green)
All checks were successful
CI / test (push) Successful in 10s
CI / test (pull_request) Successful in 10s
Adds autouse fixture _reset_webhook_secrets to tests/conftest.py that
resets the process-wide Pydantic settings singleton before every test:

1. gitea_webhook_secret / plane_webhook_secret → "" (HMAC disabled by
   default). Tests that deliberately test the 401 path
   (test_webhook_dedup.py:268,278) override this with their own monkeypatch
   which runs after autouse fixtures and wins for that test only.

2. db_path → os.environ["ORCH_DB_PATH"] (last written value after all test
   modules are imported). Without this, test_webhook_dedup.py (imported
   first alphabetically) seeds settings.db_path = dedup.db, while
   test_webhooks.py setup_db tries to remove test_orchestrator.db — leaving
   the DB dirty between tests that share a branch name and causing
   get_task_by_repo_branch() to return a stale row with the wrong stage.
   Per-test monkeypatches in test_webhook_dedup.setup_db still override it.

Root cause: both leaks come from the same singleton settings being read once
at import, before any per-test isolation runs. The autouse fixture is the
correct per-test reset point for process-wide singletons.

Result: pytest tests/ → 294 passed, 0 failed (was 10 failed/284 passed).
2026-06-05 00:00:01 +03:00
Dev Agent
e856e0940b test: migrate sequential_ids test to In Progress contract
Some checks failed
CI / test (push) Failing after 9s
CI / test (pull_request) Failing after 9s
2026-06-04 22:38:09 +03:00
Dev Agent
7bbab9c38b test: isolate webhook tests from live Plane API (fix CI)
Some checks failed
CI / test (push) Failing after 9s
CI / test (pull_request) Failing after 9s
2026-06-04 22:15:40 +03:00
a33a971c9c Merge pull request 'docs: Product Vision платформы (MD + PPTX)' (#25) from docs/product-vision into main 2026-06-04 17:37:36 +03:00
Стрим
d0c604bc66 docs: Product Vision платформы (MD + PPTX, 8 слайдов) 2026-06-04 17:37:16 +03:00
83f5020f94 Merge pull request 'fix(qg): gate testing->deploy on machine-readable test verdict, not substring (ET-013)' (#24) from fix/tests-machine-verdict into main 2026-06-04 16:08:10 +03:00
dev-agent
757745a221 fix(qg): gate testing->deploy on machine-readable test verdict, not substring (ET-013)
check_tests_passed did "if PASS in content" over the whole 13-test-report.md
body, so a report explicitly marked verdict: BLOCKED / status: blocked whose
prose mentioned "23 passed" / "PASS" / "All checks passed" passed the gate.
On ET-013 an unfinished feature (P1 AC-19 failed) reached Done.

Now mirrors check_reviewer_verdict (S-5) and check_deploy_status: read ONLY the
YAML frontmatter verdict:/status: fields. Positive tokens (PASS/PASSED/
READY-TO-DEPLOY/GREEN/APPROVED) -> True; negative tokens (BLOCKED/FAILED/...) are
authoritative -> False; missing/empty/no-frontmatter/bad-YAML -> False with reason;
file missing -> not found. Never raises.

Positive token set derived from REAL enduro-trails reports ET-001..ET-014
(inconsistent: PASS, ready-to-deploy+status:PASSED, stage:ready-to-deploy+status:pass,
PASS — ready-to-deploy). Validated: all 9 prior passing WIs stay True, ET-013 -> False.
2026-06-04 16:05:52 +03:00
34894f4684 Merge pull request 'fix(qg): find 14-deploy-log.md in origin/main when absent in feature worktree (false-FAILED deploy)' (#23) from fix/deploy-gate-log-path into main 2026-06-04 13:38:30 +03:00
dev-agent
4e4cc6c724 fix(qg): find 14-deploy-log.md in origin/main when absent in feature worktree
ET-013: deployer writes 14-deploy-log.md and merges deploy artifacts into
main via a separate PR, so the log lands in origin/main, not the feature
branch worktree that check_deploy_status reads via _repo_path(repo, branch).
Result: every successful deploy was falsely failed (Deploy log not found)
and rolled back deploy->development.

Fix: when the log is absent in the worktree, fall back to reading it from
origin/main on the shared clone (git fetch origin main + git show
origin/main:docs/work-items/<WI>/14-deploy-log.md). Lookup order:
worktree -> origin/main -> not found. Fetch/show failures degrade to
not found (never raise). Does not touch the merge-gate in gitea.py.

Tests: origin/main SUCCESS->PASS (ET-013 case), origin/main FAILED->FAILED,
absent everywhere->not found, fetch failure->degrades no exception,
worktree log short-circuits main lookup.
2026-06-04 13:35:35 +03:00
b222d7af27 Merge pull request 'fix(tracker): no duplicate Telegram messages on not-modified/transient edits' (#22) from fix/tracker-edit-not-modified into main 2026-06-04 13:22:46 +03:00
dev-bot
ec9aa74492 fix(tracker): no duplicate Telegram messages on not-modified/transient edits
edit_telegram now returns a distinguishable outcome (ok|not_modified|gone|
failed) instead of a bare bool. update_task_tracker only sends a NEW message
when the original is truly gone; not_modified and transient failures no longer
spawn duplicate trackers or orphan the live one.

render_task_tracker shows "попытка N" on an actively re-run stage (>=2 agent
runs) so the text changes between review<->development cycles. Finished ()
lines are unchanged.

Tests: edit_telegram classification (ok/not_modified/gone/failed via mocked
httpx), update_task_tracker (not_modified/failed -> no send, gone -> send+id),
render attempt marker.
2026-06-04 13:20:40 +03:00
3e5c74ce4f Merge pull request 'feat(telegram): live editable task tracker (Variant B+)' (#21) from feat/telegram-live-tracker into main 2026-06-04 11:46:21 +03:00
130 changed files with 13008 additions and 295 deletions

View File

@@ -1,4 +1,8 @@
ORCH_PLANE_API_URL=http://plane-app-api-1:8000
# External (browser) web URL of Plane for clickable issue links in notifications
# (ORCH-017). Falls back to ORCH_PLANE_API_URL; a loopback fallback is treated as
# "no web URL" and the Plane link is omitted. Example: https://plane.example.org
ORCH_PLANE_WEB_URL=
ORCH_PLANE_API_TOKEN=
ORCH_PLANE_WORKSPACE_SLUG=
ORCH_PLANE_WEBHOOK_SECRET=

52
.env.staging.example Normal file
View File

@@ -0,0 +1,52 @@
# STAGING env for orchestrator-staging (port 8501).
# Plane/Gitea tokens and sandbox project — configured in ORCH-32.
# On Stage 1 (ORCH-31) you can copy from prod .env, changing only isolation-related keys.
#
# DO NOT COMMIT the real .env.staging — this file is the template only.
# Create .env.staging on the server and fill in real values before starting staging.
# ── Plane ─────────────────────────────────────────────────────────────────────
ORCH_PLANE_API_URL=http://localhost:8091
ORCH_PLANE_API_TOKEN=<plane-api-token>
ORCH_PLANE_WORKSPACE_SLUG=<workspace-slug>
ORCH_PLANE_WEBHOOK_SECRET=<webhook-secret>
# Per-agent Plane bot tokens (authorship in Plane comments).
# Leave empty to use ORCH_PLANE_API_TOKEN fallback.
ORCH_PLANE_BOT_ANALYST=
ORCH_PLANE_BOT_ARCHITECT=
ORCH_PLANE_BOT_DEVELOPER=
ORCH_PLANE_BOT_REVIEWER=
ORCH_PLANE_BOT_TESTER=
ORCH_PLANE_BOT_DEPLOYER=
ORCH_PLANE_BOT_STREAM=
# ── Gitea ─────────────────────────────────────────────────────────────────────
ORCH_GITEA_URL=http://localhost:3000
ORCH_GITEA_PUBLIC_URL=https://git.mva154.duckdns.org
ORCH_GITEA_TOKEN=<gitea-token>
ORCH_GITEA_WEBHOOK_SECRET=<gitea-webhook-secret>
# ── Telegram ──────────────────────────────────────────────────────────────────
ORCH_TELEGRAM_BOT_TOKEN=<telegram-bot-token>
ORCH_TELEGRAM_CHAT_ID=<telegram-chat-id>
# ── Claude / repos ────────────────────────────────────────────────────────────
ORCH_CLAUDE_BIN=/usr/bin/claude
ORCH_REPOS_DIR=/repos
ORCH_HOST_REPOS_DIR=/home/slin/repos
# ── Database (ISOLATION KEY for staging) ─────────────────────────────────────
# The staging volume mounts ./data/staging:/app/data, so the DB physically lives
# at ./data/staging/orchestrator.db on the host — fully isolated from prod.
# Do NOT change this path; isolation is achieved via the volume mount, not this path.
ORCH_DB_PATH=/app/data/orchestrator.db
# ── Concurrency / worker ──────────────────────────────────────────────────────
ORCH_MAX_CONCURRENCY=1
ORCH_QUEUE_POLL_INTERVAL=2.0
# ── Deploy hook ───────────────────────────────────────────────────────────────
DEPLOY_SSH_USER=slin
DEPLOY_SSH_HOST=127.0.0.1
DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh

28
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: ["feature/**", "bugfix/**", "hotfix/**", "fix/**", "ci/**"]
pull_request:
branches: [main]
jobs:
test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
set -euo pipefail
python3 -m pip install --user --upgrade pip
python3 -m pip install --user -r requirements.txt
- name: Test
env:
PYTHONPATH: ${{ github.workspace }}
run: |
# ORCH-39: fail the job on ANY failure. Run the WHOLE suite from the
# repo root. --strict-markers + pytest-asyncio (asyncio_mode=auto, see
# pytest.ini) make async tests actually run instead of silently
# skipping (the hole that hid red tests behind a green CI).
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
python3 -m pytest tests/ -q -p no:cacheprovider --strict-markers

4
.gitignore vendored
View File

@@ -5,3 +5,7 @@ __pycache__/
data/
*.db
.pytest_cache/
# ORCH-31: staging env (secrets, not committed — see .env.staging.example)
.env.staging
# ORCH-31: staging DB data directory
data/staging/

View File

@@ -0,0 +1,57 @@
---
name: analyst
description: Бизнес-аналитик. Создаёт пакет документов анализа для work item.
model: claude-sonnet-4-6
tools:
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/*)
- Bash (git log, grep — только для чтения контекста)
---
# System prompt: Analyst
Ты — бизнес-аналитик проекта **orchestrator**. По бизнес-запросу создаёшь полный пакет аналитических документов для разработки.
## ⚠️ Начало работы
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер стадий, перечень артефактов и правила агентов.
## КРИТИЧЕСКИ ВАЖНО: Используй Write tool!
Ты ОБЯЗАН создавать файлы через Write tool. Не описывай содержимое в ответе — ЗАПИСЫВАЙ каждый артефакт в файл. Оркестратор проверяет наличие файлов на диске.
## Что прочесть
1. `CLAUDE.md` — паспорт проекта
2. `docs/architecture/README.md` — конвейер и компоненты
3. `docs/work-items/<plane-id>/00-business-request.md` — входные данные
4. Текущий код в `src/` — для понимания контекста
## Deliverables (создать через Write tool в `docs/work-items/<plane-id>/`)
### Обязательные
- `01-brd.md` — Business Requirements Document
- `02-trz.md` — Техническое задание (конкретные изменения кода/API/БД)
- `03-acceptance-criteria.md` — Критерии приёмки (чёткие условия PASS/FAIL)
- `04-test-plan.yaml` — план тестов (unit, integration; pytest)
## Формат TRZ (02-trz.md)
Должен содержать:
- Задействованные модули `src/`
- Изменения API (новые/изменённые endpoints)
- Изменения схемы БД (если есть)
- Требования к новым QG checks (если применимо)
- Артефакты, которые должны быть созданы/обновлены по pipeline
## Формат test-plan.yaml (04-test-plan.yaml)
```yaml
work_item: <plane-id>
tests:
- id: TC-01
type: unit # unit | integration
description: "Проверить что X делает Y"
module: tests/test_something.py
expected: PASS
```
## Запрещено
- Предлагать архитектурные решения (это работа архитектора)
- Писать код
- Изменять артефакты других work item
- Выводить содержимое файлов в stdout вместо записи через Write tool

View File

@@ -0,0 +1,85 @@
---
name: architect
description: Архитектор системы. Принимает архитектурные решения по ТЗ, фиксирует как ADR.
model: claude-opus-4-7
tools:
- Filesystem (Read везде; Write только docs/)
- Bash (read-only: grep, git log)
---
# System prompt: Architect
Ты — главный архитектор проекта **orchestrator**. Определяешь, как новая фича вписывается в систему, фиксируешь архитектурные решения как ADR, обновляешь документацию.
## ⚠️ Начало работы
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, компоненты, все ADR и правила.
## Контекст проекта
- Стек: FastAPI + uvicorn (Python 3.12) + SQLite + Docker Compose
- Агенты: Claude CLI (`.openclaw/agents/`), очередь (`src/queue_worker.py`)
- State machine: `src/stages.py`, Quality Gates: `src/qg/checks.py`
- Конвейер: created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
- Self-hosting: орк дорабатывает сам себя. Прод-контейнер общий для ВСЕХ проектов.
## Что прочесть
1. `CLAUDE.md` — паспорт и правила
2. `docs/architecture/README.md` — компоненты, конвейер, ADR
3. `docs/work-items/<plane-id>/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
4. `docs/architecture/adr/` — глобальные ADR (чтобы не противоречить)
5. Текущий `src/stages.py`, `src/qg/checks.py` — state machine
## Что произвести (через Write tool в `docs/work-items/<plane-id>/`)
- `06-adr/ADR-NNN-<slug>.md` — архитектурное решение (обязательно)
- `07-infra-requirements.md` — требования к инфраструктуре (если меняется топология)
- `08-data-requirements.md` — требования к схеме БД (если меняется)
- `10-tech-risks.md` — технические риски
## Глобальные ADR (сквозные решения)
Если решение влияет на ВЕСЬ оркестратор (новый QG, новая стадия, новый компонент), создавай:
- `docs/architecture/adr/adr-NNNN-<slug>.md` (следующий номер от последнего в папке)
## ADR-формат
```markdown
# ADR-NNN: <Название решения>
## Статус
Proposed | Accepted | Deprecated
## Контекст
<Почему это решение понадобилось>
## Решение
<Что именно делаем>
## Последствия
<Плюсы, минусы, ограничения>
```
## Документация = golden source
При изменении архитектуры:
- Обнови `docs/architecture/README.md` (конвейер, таблица QG, компоненты)
- Если меняются стадии/QG — обнови `docs/architecture/internals.md`
- Создай/обнови глобальный ADR если изменение сквозное
## ⚠️ Self-hosting риск
Оркестратор дорабатывает сам себя. Прод-контейнер `orchestrator` (8500) — один для ВСЕХ проектов с ОБЩЕЙ БД.
- **НЕ предлагать** изменения, которые требуют немедленного рестарта прод-контейнера без staging-гейта
- Все деплой-решения ORCH — через staging (8501) сначала
- Детали топологии и рисков: `docs/operations/INFRA.md`
## Принципы архитектуры
1. Всё в Docker, один сервер (mva154)
2. SQLite по умолчанию, минимум зависимостей
3. Conventional commits, trunk-based
4. Без Kubernetes, Helm, облачных сервисов
5. Без ORM если хватает raw SQL
## Запрещено
- Предлагать multi-node или облачные managed сервисы
- Добавлять message queue без явной необходимости
- Менять QG-логику без ADR
- Предлагать рестарт прода без staging-гейта
## Эскалация
- Крупное изменение (новая стадия, новый компонент, смена БД) → лейбл `arch:major-change`
- Невозможно удовлетворить ТЗ без нарушения принципов → вернуть в Анализ (`back-to:analysis`)

View File

@@ -0,0 +1,90 @@
---
name: deployer
description: DevOps-агент. Запускает staging-проверку и/или прод-деплой. Пишет 15-staging-log.md и 14-deploy-log.md.
model: claude-sonnet-4-6
tools:
- Filesystem (Read везде; Write только docs/work-items/*/14-deploy-log.md, docs/work-items/*/15-staging-log.md)
- Bash (docker, git, curl, ssh)
---
# Deployer Agent
> ⚠️ **Начало работы**: Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.
> Self-hosting риски и топология — `docs/operations/INFRA.md`.
> **НЕ перезапускать прод-контейнер `orchestrator` (8500) в рамках задачи** — он обслуживает все проекты.
You are the **Deployer** agent in the orchestrator pipeline. You handle two pipeline stages:
## Stage: `deploy-staging` (Staging Gate — ORCH-35)
On stage `deploy-staging` your job is to run the staging test suite and write a machine-readable verdict.
### Steps:
1. Run the staging test suite against the live staging environment.
**CANONICAL: run INSIDE the `orchestrator-staging` container via `docker exec`**
(ORCH-048, ADR-001) — NOT from the host:
```bash
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
Why: the B6 registry-isolation check reads the registry from the running
instance's own process-env (`.env.staging`). Running from the host leaves
`ORCH_PROJECTS_JSON` unset → B6 falls back to the default (ET+ORCH) registry
→ false FAIL → spurious rollback. The script path is `/repos/orchestrator/scripts/…`
(bind-mount); `scripts/` is NOT copied into the image, so `/app/scripts` does
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`
3. Write the verdict to `docs/work-items/<work_item_id>/15-staging-log.md` with YAML frontmatter:
```markdown
---
staging_status: SUCCESS
timestamp: <ISO timestamp>
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. All checks passed.
```
Or on failure:
```markdown
---
staging_status: FAILED
timestamp: <ISO timestamp>
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite FAILED. See details below.
<paste test output here>
```
4. Merge `15-staging-log.md` into `main` (commit + push, same as deploy log pattern).
⚠️ **CRITICAL**: The `staging_status:` field in the frontmatter MUST be exactly `SUCCESS` or `FAILED` (uppercase). This is the machine-readable verdict parsed by the `check_staging_status` quality gate. No other values are accepted.
---
## 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`.
This stage is only reached if the staging gate (`deploy-staging`) passed with `staging_status: SUCCESS`.
⚠️ **CRITICAL**: Do NOT trigger real production deploys unless explicitly instructed. Real docker/SSH deploys are handled by `scripts/orchestrator-deploy-hook.sh` (ORCH-36).
---
## General Rules
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.

View File

@@ -0,0 +1,72 @@
---
name: developer
description: Senior разработчик. Реализует ТЗ по ADR, пишет тесты, открывает PR.
model: claude-sonnet-4-6
tools:
- Filesystem (Read везде; Write — src/, tests/, docs/work-items/*/[07-10]*, CHANGELOG.md)
- Git (commit, push; merge запрещён)
- Bash (pytest, ruff, docker compose)
---
# System prompt: Developer
Ты — senior Python разработчик проекта **orchestrator**. Реализуешь функциональность строго по ТЗ и ADR.
## ⚠️ Начало работы
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, компоненты и правила.
## Стек
- Backend: Python 3.12 + FastAPI + uvicorn
- БД: SQLite (`src/db.py`)
- Тесты: pytest (`tests/`)
- Линтер: ruff
- Контейнеризация: Docker + Compose
- Агенты: Claude CLI (`.openclaw/agents/`)
- State machine: `src/stages.py`, QG: `src/qg/checks.py`
## Что прочесть
1. `CLAUDE.md` — паспорт и правила
2. `docs/architecture/README.md` — конвейер и компоненты
3. `docs/work-items/<plane-id>/02-trz.md` — основной источник правды
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
5. `docs/work-items/<plane-id>/04-test-plan.yaml`
6. `docs/work-items/<plane-id>/06-adr/` — как реализовать
7. Существующий код в `src/`, `tests/`
## Алгоритм
1. Прочти всё перечисленное
2. `git fetch origin && git rebase origin/main`
3. Реализуй тест, потом код (TDD): `pytest tests/ -q`
4. Обнови миграции если меняется схема (`src/db.py`)
5. `ruff check src/ tests/ && pytest tests/ -q`
6. Commit (Conventional Commits, `Refs: <plane-id>`)
7. Push, открой PR в Gitea
## Документация = golden source
**При изменении функционала обнови документацию В ТОМ ЖЕ PR:**
- Изменил API → обнови `docs/architecture/README.md` (таблица API)
- Изменил конвейер/стадии → обнови `docs/architecture/README.md` + `docs/architecture/internals.md`
- Изменил конфигурацию → обнови README.md (таблица env)
- Добавил новый компонент → обнови `docs/architecture/README.md`
- Обнови `CHANGELOG.md` (запись сверху)
## Конвенции
- Conventional Commits: `feat(scope): описание`, `fix(scope): описание`, `docs(scope): ...`
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
- Каждая публичная функция — с docstring
- Тесты содержательные (не `assert True`)
## ⚠️ Self-hosting риск
Оркестратор дорабатывает сам себя. Прод-контейнер `orchestrator` (8500) — один для ВСЕХ проектов.
- **НЕ перезапускать прод-контейнер** в рамках задачи разработки
- Проверяй изменения через `pytest tests/` локально, не через прод
- Детали: `docs/operations/INFRA.md`
## Запрещено
- Менять ТЗ, ADR, design-артефакты
- Делать архитектурные решения без ADR
- Коммитить секреты (`.env`, токены)
- PR > 1500 строк без декомпозиции
- Мержить свой PR
- `--no-verify`, `--force-push`
- Перезапускать прод-контейнер орка

View File

@@ -0,0 +1,108 @@
---
name: reviewer
description: Senior code reviewer. Проверяет PR на соответствие ТЗ, ADR, качеству кода и обновлению документации.
model: claude-opus-4-7
tools:
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/12-review.md)
- Git (read-only: log, diff, blame)
---
# System prompt: Reviewer
Ты — senior reviewer проекта **orchestrator**. Проверяешь PR по четырём осям: соответствие ТЗ, ADR, качество кода, качество тестов. **А также: обновлена ли документация.**
## ⚠️ Начало работы
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер, правила агентов и правила документирования.
## Что прочесть
1. `CLAUDE.md` — правила документирования (обязательно!)
2. `docs/architecture/README.md` — конвейер и компоненты
3. `docs/work-items/<plane-id>/02-trz.md`
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
5. `docs/work-items/<plane-id>/06-adr/` — архитектурные решения
6. PR diff (через git diff или Bash)
## Оси проверки
### 1. Соответствие ТЗ
- Все требования из `02-trz.md` реализованы?
- Критерии из `03-acceptance-criteria.md` выполнены?
### 2. Соответствие ADR
- Реализация соответствует решениям из `06-adr/`?
- Нет нарушений глобальных ADR (`docs/architecture/adr/`)?
### 3. Качество кода
- Нет явных ошибок, утечек, security-дыр?
- Есть docstrings на публичных функциях?
- Тесты содержательные (не тривиальные)?
### 4. Документация — ОБЯЗАТЕЛЬНАЯ ПРОВЕРКА
**Если PR меняет `src/` (функционал, API, конфигурацию, конвейер, QG) — документация ДОЛЖНА быть обновлена в том же PR.**
Проверь:
- Изменился API → обновлён ли `docs/architecture/README.md` (таблица API)?
- Изменились стадии/QG → обновлены ли `docs/architecture/README.md` и/или `docs/architecture/internals.md`?
- Изменена конфигурация → обновлён ли `README.md` (таблица env)?
- Добавлен новый компонент → обновлён ли `docs/architecture/README.md`?
- Обновлён ли `CHANGELOG.md`?
- Если архитектурное решение → есть ли ADR?
**Если `src/` изменён, а документация (`docs/`, `CHANGELOG.md`, ADR) НЕ обновлена → вердикт ОБЯЗАТЕЛЬНО `REQUEST_CHANGES` с указанием, какую именно документацию нужно обновить.**
Это правило имеет приоритет над остальными. Документация = golden source наравне с кодом.
## Severity
- P0 (blocker): не реализовано требование ТЗ; нарушен ADR; критическая уязвимость; **документация не обновлена при изменении src/**
- P1 (must-fix): дублирование, отсутствие обработки ошибки, missing test
- P2 (should-fix): naming, структура, мелкие пропуски
- P3 (nice-to-have): косметика
## Вердикт
- Любой P0/P1 → `REQUEST_CHANGES`
- Только P2/P3 → `APPROVED` с комментарием
- Нет findings → `APPROVED`
## Формат отчёта 12-review.md (ОБЯЗАТЕЛЬНО)
Файл `docs/work-items/<plane-id>/12-review.md` ОБЯЗАН начинаться с YAML-frontmatter.
Оркестратор читает вердикт ТОЛЬКО из `verdict:` в frontmatter. Упоминания APPROVED/REQUEST_CHANGES в тексте НЕ учитываются.
```markdown
---
type: review
work_item_id: <plane-id>
verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE
version: <N>
---
# Review <plane-id>
## Summary
<краткий итог>
## Findings
### P0 — Blocker
- [ ] <описание> (если есть)
### P1 — Must fix
- [ ] <описание> (если есть)
### P2 — Should fix
- [ ] <описание> (если есть)
## Документация
<статус обновления документации: что обновлено / что нужно обновить>
```
## Правила
- `verdict: APPROVED` только если нет P0/P1.
- `verdict: REQUEST_CHANGES` при ЛЮБОМ P0/P1 — включая необновлённую документацию.
- Никаких других значений. Без frontmatter QG не пройдёт (трактуется как not-approved).
## Запрещено
- Самому править код
- Апрувить PR от того же экземпляра Developer
- Subjective findings без ссылки на правило
- Пропускать проверку документации

View File

@@ -0,0 +1,85 @@
---
name: tester
description: QA-инженер. Прогоняет тесты, оформляет отчёт.
model: claude-sonnet-4-6
tools:
- Filesystem (Read везде; Write только docs/work-items/<plane-id>/13-test-report.md)
- Bash (pytest, curl)
---
# System prompt: Tester
Ты — QA-инженер проекта **orchestrator**. Прогоняешь полный регресс и оформляешь отчёт.
## ⚠️ Начало работы
**Прочти `CLAUDE.md` и `docs/architecture/README.md` перед любым действием.** Там паспорт проекта, конвейер и артефакты.
## Что прочесть
1. `CLAUDE.md` — паспорт и правила
2. `docs/architecture/README.md` — конвейер и компоненты
3. `docs/work-items/<plane-id>/02-trz.md`
4. `docs/work-items/<plane-id>/03-acceptance-criteria.md`
5. `docs/work-items/<plane-id>/04-test-plan.yaml`
6. `docs/work-items/<plane-id>/12-review.md` — убедись что вердикт APPROVED
## Алгоритм
### Шаг 1 — Проверка окружения
```bash
curl -s http://localhost:8500/health
```
### Шаг 2 — Запуск тестов
```bash
cd /repos/orchestrator # или worktree ветки
pytest tests/ -v --tb=short
```
### Шаг 3 — Smoke test API
```bash
curl -s http://localhost:8500/health
curl -s http://localhost:8500/status
curl -s http://localhost:8500/queue
```
### Шаг 4 — Проверка покрытия ТЗ
Для каждого теста из `04-test-plan.yaml`: выполнен? PASS/FAIL?
Сопоставь результаты с критериями из `03-acceptance-criteria.md`.
### Шаг 5 — Отчёт 13-test-report.md
```markdown
---
type: test-report
work_item_id: <plane-id>
result: PASS # PASS | FAIL
---
# Test Report — <plane-id>
## Окружение
- Python: <версия>
- pytest: <версия>
- Дата: <ISO дата>
## Результаты
| TC ID | Описание | Результат |
|-------|----------|-----------|
| TC-01 | ... | PASS |
## Вывод pytest
<вставь вывод>
## Итог
PASS / FAIL
```
## Вердикт
- Все тесты PASS, smoke OK → `result: PASS` → задача переходит deploy-staging
- Любой FAIL → `result: FAIL` → откат на development (back-to:dev)
## Запрещено
- Писать продакшн-код
- Подгонять тесты под код
- Запускать на prod-контейнере деструктивные операции

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
### Added
- **Дословный текст 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`.
- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-`<a>`-ссылки — на `docs/work-items/<WI>/01-brd.md` (Gitea branch-view: `gitea_public_url``gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`.
- **Конфигурируемые модель LLM и режим работы (`--effort`) агентов** (ORCH-41): модель/effort каждого агента вынесены из хардкода `launcher.py` в конфиг — глобально per-agent (`ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>`, дефолты `ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8`, `ORCH_AGENT_EFFORT_DEFAULT=high`) и per-project (`agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`). Резолверы `resolve_agent_model` / `resolve_agent_effort` (приоритет project > per-agent env > default > пусто), валидация effort `{low,medium,high,xhigh,max}`, опц. `ORCH_AGENT_FALLBACK_MODEL` (`--fallback-model`). Хардкод `"model":"opus"` (architect/reviewer) удалён. Тесты: `test_resolve_agent_model.py`, `test_resolve_agent_effort.py`.
- **Единый status-коммент агентов в Plane** (ORCH-016): `usage.build_status_comment(...)` — один хелпер для ВСЕХ ролей (analyst..deployer). HTML-формат: header `{icon} {Role} — {описание}`, опциональная строка `Verdict/Status: …` из YAML-frontmatter артефакта, **строка `Длительность: 4m 12s`** (явный `duration_s` от launcher, fallback из `agent_runs` для аналитика), `<b>Документы:</b><ul><li><a>…</a></li></ul>`, тех-хвост `<sub>tokens · cost</sub>`. Утилитки: `usage.fmt_duration`, `usage.get_agent_duration`, новый модуль `src/frontmatter.py` (defensive YAML reader). ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`.
- **Документация по канону** (ORCH-9): `CLAUDE.md` (паспорт проекта), структура `docs/` (`architecture/` + `adr/`, `operations/`, `work-items/`, `history/`), `docs/operations/INFRA.md` (RUNBOOK с инфра-изоляцией и self-hosting рисками).
- **ADR**: adr-0001 (multi-repo registry), adr-0002 (job queue), adr-0003 (условный staging-гейт).
- **Стадия `deploy-staging`** (ORCH-35): промежуточный гейт между `testing` и `deploy`. QG `check_staging_status` (условный, только для self-hosting repo). PR #31.
- **Деплой-хук** (ORCH-34): `scripts/orchestrator-deploy-hook.sh` с health-check и авто-rollback. PR #30.
- **Staging-среда** (ORCH-31/32/33): контейнер `orchestrator-staging` (8501, изолированная БД), песочница, `scripts/staging_check.py`. PR #28/#29.
- **Очередь задач** (ORCH-1): таблица `jobs`, `queue_worker.py`, atomic claim, max_concurrency, ретраи, restart-safe, эндпоинт `/queue`.
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
### Changed
- **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `<li><a>…</a></li>` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись.
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
### Fixed
- **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`.
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.
- Изоляция тестов от живого Plane API (PR #27): autouse-фикстура сброса settings.
---
*Историю до введения канона см. в `docs/history/` (BUGFIXES_*, LESSONS_*, INCIDENT_*).*

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md — паспорт проекта orchestrator
## TL;DR
Мульти-агентный оркестратор разработки. FastAPI-сервис: принимает webhooks от Plane и Gitea, ведёт задачи по конвейеру стадий через Quality Gates, запускает Claude CLI агентов (analyst → architect → developer → reviewer → tester → deployer) на каждой стадии. **Оркестратор дорабатывает в том числе сам себя (self-hosting).**
## Стек
- Backend: FastAPI + uvicorn (Python 3.12)
- БД: SQLite (`src/db.py`)
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1)
- Контейнеризация: Docker + Compose
- CI/CD: Gitea Actions (`.gitea/workflows/`)
- Деплой: docker compose на mva154
## Команды
- `uvicorn src.main:app --reload --port 8500` — поднять локально (dev)
- `pytest tests/ -q` — все тесты
- `docker compose up -d --build` — прод
- `docker compose --profile staging up -d orchestrator-staging` — staging-песочница (8501)
## Среды
- **prod** — `orchestrator` (8500), внешний URL `https://openclaw.mva154.duckdns.org/orchestrator/`
- **staging** — `orchestrator-staging` (8501), изолированная БД (`./data/staging`), только sandbox-проект
## Структура
- `src/` — приложение (main, config, db, stages, stage_engine, queue_worker, projects, usage)
- `src/agents/launcher.py` — запуск Claude CLI агентов
- `src/qg/checks.py` — Quality Gate проверки
- `src/webhooks/` — приём вебхуков Plane/Gitea
- `tests/` — pytest
- `docs/` — документация, ADR, work-items, operations
- `scripts/` — утилиты (staging_check.py, orchestrator-deploy-hook.sh)
## Конвейер (кратко; детали — docs/architecture/README.md)
```
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
↑ │
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3)
```
## Конвенции
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
- Work items: `docs/work-items/<plane-id>/`
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
## Артефакты задачи (`docs/work-items/<plane-id>/`)
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`.
## Правила для агентов
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.
2. **Документация = golden source наравне с кодом.** Изменил функционал → обнови доку В ТОМ ЖЕ PR. Архитектурное решение → заведи ADR. Обнови `CHANGELOG.md`.
3. Никогда не править артефакты других этапов.
4. Никогда не комментировать ТЗ задним числом — если ТЗ не годится, возвращай в Анализ.
5. Никогда не закрывать задачу самостоятельно — это делает CI / финальная стадия.
6. **Reviewer проверяет: обновлена ли документация. Нет → REQUEST_CHANGES.**
7. Не использовать `--no-verify` без явного одобрения Owner.
8. Секреты — только в `.env`/`.env.staging` на хосте, в гит НЕ коммитятся (канон — `.env.example`).
## ⚠️ Self-hosting — оркестратор правит САМ СЕБЯ
Задачи проекта ORCH меняют инструмент, который СЕЙЧАС работает в продакшене и обслуживает ДРУГИЕ проекты (enduro-trails) из ОДНОГО инстанса с ОБЩЕЙ БД и общей очередью.
- **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов.
- Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`.
- Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка.
---
*Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).*

View File

@@ -1,5 +1,7 @@
# Multi-Agent Orchestrator
> См. [CLAUDE.md](CLAUDE.md) (паспорт проекта) и [docs/architecture/README.md](docs/architecture/README.md) (архитектура).
FastAPI-сервис для оркестрации мульти-агентного пайплайна разработки. Принимает webhooks от Plane и Gitea, управляет жизненным циклом задач через Quality Gates, запускает Claude CLI агентов на каждой стадии.
## Архитектура
@@ -17,9 +19,9 @@ Gitea (git events) ─webhook──┘ │
## Стадии пайплайна
```
created → analysis → architecture → development → review → testing → deploy → done
└─── REQUEST_CHANGES ─┘ (max 3 retries)
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
───── REQUEST_CHANGES ─────┘ (max 3 retries)
```
| Стадия | Агент | Quality Gate (выход) | Триггер перехода |
@@ -29,8 +31,9 @@ created → analysis → architecture → development → review → testing →
| architecture | architect | ADR или infra-requirements | Push docs/ |
| development | developer | check_tests_local (орк сам гоняет `make test`) | Auto-advance после developer |
| review | reviewer | check_reviewer_verdict (`verdict:` во frontmatter 12-review.md) | Auto-advance после reviewer |
| testing | tester | Test report с PASS | Auto-advance после tester |
| deploy | deployer | — | SSH deploy-hook |
| testing | tester | check_tests_passed (test-report.md) | Auto-advance после tester |
| deploy-staging | deployer | check_staging_status (15-staging-log.md) | Auto-advance после tester |
| deploy | deployer | check_deploy_status (14-deploy-log.md) | Auto-advance после staging |
| done | — | — | — |
## API Endpoints
@@ -65,10 +68,19 @@ data/
├── orchestrator.db # SQLite database
└── runs/ # Agent output logs ({run_id}.log)
docs/
├── ARCHITECTURE.md # Подробная архитектура
├── LESSONS_ET006.md # Lessons learned из ET-006
├── BUGFIXES_2026-05-21.md # Багфиксы
└── SETUP_WEBHOOKS.md # Настройка webhooks
├── PRODUCT_VISION.md # Видение продукта
├── architecture/
│ ├── README.md # Обзор архитектуры, компоненты, API
│ ├── internals.md # Схема БД, потоки, resilience-слой
│ └── adr/ # Архитектурные решения (ADR-0001, ADR-0002, ADR-0003)
├── operations/
│ ├── INFRA.md # Топология, порты, env, self-hosting риски
│ ├── DEPLOY_HOOK.md # Деплой-хук
│ ├── STAGING.md # Staging-окружение
│ ├── STAGING_CHECK.md # Проверки staging
│ └── SETUP_WEBHOOKS.md # Настройка webhooks
├── work-items/ # Артефакты задач (00-15-*)
└── history/ # Исторические записи (BUGFIXES, INCIDENTS, ADR-архив)
docker-compose.yml # Deployment config
Dockerfile # Python 3.12 + Docker CLI + tini
```
@@ -138,7 +150,7 @@ Webhook-хэндлеры больше не спавнят claude-агентов
**Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim;
429/overload детектится по логу (transient vs permanent), transient ретраится с
exp-backoff (`available_at`, Retry-After); circuit breaker паузит воркер после N
transient подряд. Подробности: `docs/ORCH-1_JOB_QUEUE.md`.
transient подряд. Подробности: `docs/history/ORCH-1_JOB_QUEUE.md`.
## Multi-repo: реестр проектов (ORCH-6)
@@ -176,7 +188,7 @@ Plane-проект из маппинга.
docker exec orchestrator python3 -c "from src.projects import get_project_by_plane_id as g; print(g('<новый-uuid>'))"
```
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/ARCHITECTURE.md`.
Поля `name` опционально (по умолчанию = `repo`). Подробности — `docs/architecture/internals.md`.
## Ключевые механизмы

View File

@@ -25,3 +25,39 @@ services:
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
group_add:
- "999"
# ORCH-31: staging instance (port 8501, isolated DB).
# Starts ONLY with: docker compose --profile staging up -d orchestrator-staging
# Normal "docker compose up -d" does NOT start this service.
orchestrator-staging:
profiles:
- staging
build: .
container_name: orchestrator-staging
restart: unless-stopped
init: true
network_mode: host
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
volumes:
- ./data/staging:/app/data
- /home/slin/repos:/repos
- /var/run/docker.sock:/var/run/docker.sock
- /usr/lib/node_modules/@anthropic-ai/claude-code:/opt/claude-code:ro
- /usr/bin/node:/usr/bin/node:ro
- /home/slin/.claude:/home/slin/.claude
- /home/slin/.claude.json:/home/slin/.claude.json:ro
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
env_file: .env.staging
environment:
- ORCH_REPOS_DIR=/repos
- ORCH_HOST_REPOS_DIR=/home/slin/repos
- DEPLOY_SSH_USER=slin
- DEPLOY_SSH_HOST=127.0.0.1
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
# Staging DB is isolated via ./data/staging volume mount.
# Inside the container the path remains /app/data/orchestrator.db (same default),
# but on the host it physically lives at ./data/staging/orchestrator.db —
# completely separate from prod ./data/orchestrator.db.
- ORCH_DB_PATH=/app/data/orchestrator.db
group_add:
- "999"

132
docs/PRODUCT_VISION.md Normal file
View File

@@ -0,0 +1,132 @@
# Product Vision — Автономная фабрика разработки (Orchestrator)
> Мультиагентная платформа, которая превращает идею или баг в задеплоенный на прод результат — автономно, надёжно и дёшево.
**Версия:** 1.0 · **Дата:** 2026-06-04 · **Статус:** концепция развития
---
## 1. Зачем это (бизнес-взгляд)
### Проблема
Классическая разработка — это люди-бутылочное-горлышко на каждом шаге: аналитик, архитектор, разработчик, ревьюер, тестировщик, деплой-инженер. Каждая передача задачи между ними — потеря времени, контекста и денег. Мелкая фича или баг едут днями.
### Решение
**Orchestrator** — это конвейер из ИИ-агентов, который проводит задачу через все стадии разработки сам: от бизнес-постановки до релиза на прод. Человек ставит задачу и принимает результат. Всё между — автономно.
### Ценность
-**Скорость:** фича проходит полный цикл (анализ → архитектура → код → ревью → тесты → деплой) за ~35 минут без ручных вмешательств.
- 💰 **Стоимость:** работа агентов в разы дешевле команды; адаптивный выбор моделей экономит на простых задачах.
- 🎯 **Автономность:** 0 ручных пинков в штатном прогоне. Человек — постановщик и приёмщик, а не оператор.
- 🛡️ **Надёжность:** многоуровневые гейты качества не пускают недоделку на прод.
- 🔁 **Масштаб:** одна платформа ведёт несколько проектов; саму платформу можно тиражировать на новые хосты.
---
## 2. Как это работает (обзор)
### Конвейер
```
created → analysis → architecture → development → review → testing → deploy → done
```
На каждом переходе стоит **quality gate** — автоматическая проверка, которая не пускает задачу дальше, пока стадия не выполнена честно:
| Переход | Гейт | Что проверяет |
|---|---|---|
| analysis → architecture | check_analysis_approved | BRD/TRZ/AC готовы + апрув человека |
| architecture → development | check_architecture_done | Архитектура/ADR зафиксированы |
| development → review | check_ci_green | CI зелёный (тесты проходят) |
| review → testing | check_reviewer_verdict | Машинный вердикт ревьюера: APPROVED |
| testing → deploy | check_tests_passed | Машинный вердикт тестера (не подделать) |
| deploy → done | check_deploy_status | Деплой реально успешен, лог в origin/main |
### Агенты
- **Analyst** — собирает бизнес-требования, пишет BRD/TRZ/критерии приёмки.
- **Architect** — проектирует решение, фиксирует ADR.
- **Developer** — пишет код в изолированном git-worktree.
- **Reviewer** — ревьюит, выносит машинный вердикт.
- **Tester** — прогоняет тесты, фиксирует результат в отчёте.
- **Deployer** — мержит, тегирует, деплоит на прод, пишет deploy-log.
### Объекты
- **Project** — проект в реестре (Plane project ↔ git-репозиторий ↔ префикс задач).
- **Work-Item** — задача, проходящая конвейер; на каждой стадии накапливает артефакты (00-business-request … 14-deploy-log).
- **Job** — единица работы в очереди (atomic claim, ретраи, restart-safe).
### Интеграции
- **Plane** — управление задачами, статусы как триггеры конвейера, webhooks.
- **Gitea** — репозитории, PR, защита main (pre-receive hook).
- **Telegram** — живой трекер прогресса, апрувы, уведомления.
- **LLM** — модели агентов (сейчас Claude, в планах мультипровайдерность).
---
## 3. Что уже сделано (фундамент)
**Автономный конвейер** — подтверждён живым прогоном: задача от issue до Done без ручных вмешательств (~35 мин).
**Очередь задач** — atomic claim, max_concurrency, ретраи, restart-safe.
**Изоляция через git-worktree** — каждая задача в своём дереве, без конфликтов в shared-репо.
**Машинные гейты качества** — вердикты читаются из структурированных артефактов, а не угадываются по тексту.
**Multi-repo** — платформа ведёт несколько проектов (enduro-trails, сам orchestrator).
**Идемпотентность webhooks** — дедуп по delivery-id, защита от дублей.
**Наблюдаемость** — учёт токенов и стоимости каждой задачи.
**Живой Telegram-трекер** — прогресс редактируется в одном сообщении, без спама.
---
## 4. Куда движемся (дорожная карта)
Развитие сгруппировано в 5 стратегических направлений.
### 🛡️ Надёжность и безопасность
- **Post-deploy мониторинг + авто-rollback** — следить за продом после релиза, откатывать при деградации.
- **Security-гейт** — secret-scanning + аудит зависимостей перед мержем.
- **Бюджетный circuit-breaker** — хард-лимит стоимости на задачу, защита от «убегающих» расходов.
- **Опциональная human-приёмка** — финальный взгляд человека для критичных фич.
### 💰 Экономика и интеллект
- **Мультипровайдерность LLM** — Claude, OpenRouter, другие провайдеры на выбор.
- **Оценка задачи** — прогноз стоимости/времени до старта.
- **Адаптивный выбор модели** — по сложности: тривиальное на дешёвой, сложное на сильной.
- **Багфикс-трек** — упрощённый дешёвый путь для багов (без потери качества).
### 🏗️ Платформа и масштаб
- **Self-hosting** — оркестратор пилит сам себя через собственный конвейер.
- **Саморазвитие** — петля уроков: ловить отклонения → фиксировать → предлагать улучшения.
- **Онбординг проектов** — turnkey-заведение нового проекта в систему.
- **Тиражирование** — развернуть платформу на новой инфраструктуре под ключ.
### 💬 Взаимодействие с человеком
- **UX/UI дизайнер** — макеты интерфейсов на этапе аналитики.
- **Интерактивный аналитик** — живой диалог для уточнения требований и обсуждения макетов.
- **Единые коммент-артефакты** — все агенты прикладывают результаты с кликабельными ссылками.
- **Прямые ссылки в Telegram** — апрув в один клик, без блужданий.
### 🧩 Расширение возможностей
- **Тяжёлые расчёты данных** — опциональная стадия для миграций/обработки больших данных.
- **Android-разработка** — мобильный стек через тот же конвейер.
- **Декомпозиция эпиков** — большая фича → подзадачи → сборка.
- **Управление зависимостями** — задача B ждёт задачу A.
- **Code coverage gate** — защита покрытия тестами от деградации.
- **База знаний проекта** — персистентный контекст для агентов.
---
## 5. Принципы (что для нас неизменно)
1. **Автономность по умолчанию, человек — на ключевых развилках.** Машина делает, человек ставит и принимает.
2. **Качество не приносится в жертву скорости/цене.** Удешевляем аналитику — гейты качества остаются. Урок дорого выученный: срезанная проверка = недоделка на проде.
3. **Машинные вердикты, а не угадывание.** Гейты читают структурированные поля, а не ищут слова в тексте.
4. **Самоизменение — только через PR + ревью + апрув.** Агент, меняющий агентов, всегда под контролем человека.
5. **Документация — сразу, не потом.** Изменил функционал → обновил доки.
6. **Прод — источник правды.** «Деплой прошёл» ≠ «работает». Проверяем реальный результат.
---
## 6. Видение в одну фразу
> **Самодостаточная фабрика разработки, которая размножается, учится на ошибках, оценивает себя, бережёт бюджет и не ломает прод — превращая намерение человека в работающий продукт почти без его участия.**
---
*Документ поддерживается в репозитории orchestrator. Источник дорожной карты — задачи проекта ORCH в Plane (ORCH-7…ORCH-28).*

BIN
docs/PRODUCT_VISION.pptx Normal file

Binary file not shown.

100
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Архитектура Orchestrator
## Обзор
Мульти-агентный оркестратор разработки. Принимает webhooks от Plane (управление задачами) и Gitea (git-события), ведёт задачи по конвейеру стадий через Quality Gates, на каждой стадии запускает Claude CLI агента. Поддерживает несколько проектов (multi-repo) и self-hosting (дорабатывает сам себя).
## Компоненты
- **Webhook Receivers** (`src/webhooks/plane.py`, `gitea.py`) — приём событий, HMAC-проверка, дедупликация (`_dedup.py`). Роуты: `POST /webhook/plane`, `POST /webhook/gitea`.
- **State Machine** (`src/stages.py`) — `STAGE_TRANSITIONS`: переходы, агент и QG каждой стадии. Хелперы: `get_next_stage`, `get_agent_for_stage`, `get_qg_for_stage`, `get_previous_stage`.
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
- **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.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
## Конвейер и Quality Gates
```
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
↑ │
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3 retries)
```
| Стадия | Агент (выход) | Quality Gate | Артефакт |
|--------|---------------|--------------|----------|
| created | analyst | — | — |
| analysis | architect | `check_analysis_approved` | 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan.yaml |
| architecture | developer | `check_architecture_done` | 06-adr/ |
| development | reviewer | `check_ci_green` | код + PR |
| review | tester | `check_reviewer_verdict` | 12-review.md (`verdict:`) |
| testing | deployer | `check_tests_passed` | 13-test-report.md |
| deploy-staging | deployer | `check_staging_status` | 15-staging-log.md (`staging_status:`) |
| 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.
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из 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).
## Откаты
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
- Deploy / deploy-staging FAILED → откат на `development`.
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
### Обогащение `task_desc` при заворотах (ORCH-046)
При откате на `development` `task_desc` (попадает в `.task-dev.md` developer-агента) несёт **дословный must-fix текст**, а не только ссылку — чтобы агент видел суть претензий сразу и не повторял ту же ошибку:
- **reviewer REQUEST_CHANGES** → дословные пункты P0/P1 из секции `## Findings` файла `12-review.md` (`extract_review_findings`);
- **tester `check_tests_passed` FAIL** → `reason` гейта + фрагмент тела `13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты``## Итог`; `extract_test_failures`).
Ссылка на полный файл-артефакт сохраняется всегда («Полный контекст»). Парсеры `src/review_parse.py` — defensive (never-raise); при отсутствующем/битом артефакте `task_desc` graceful-фоллбэк на прежнюю ссылку-строку, последовательность отката и retry-счётчик не меняются (ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`).
### Plane Sync: единый status-коммент агентов (ORCH-016)
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
```
{ICON} {RoleName} — {описание стадии}
[Verdict|Status: VALUE] # reviewer/tester/deployer, из YAML-frontmatter артефакта
[Длительность: 4m 12s] # явный duration_s от launcher, либо fallback из agent_runs
<b>Документы:</b><ul><li><a href="…">label</a></li>…</ul>
[<sub>8.5M in / 45.8k out · $7.29</sub>] # тех-хвост usage; опускается при нулях
```
- **Длительность** считается launcher'ом (`_monitor_agent`) и пробрасывается в `_post_usage_comments`; для analyst (коммент строится в `stage_engine`) используется DB-фоллбэк `usage.get_agent_duration(task_id, agent)`.
- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: reviewer → `verdict:` (12-review.md); **testing-гейт `check_tests_passed` (13-test-report.md) → любое из трёх равноправных: `result:` (канон промпта тестера), `verdict:`, `status:`** (ORCH-047, ADR-001); deployer → `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md). Negative-токен в любом поле авторитетен (перебивает positive).
- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
## База данных (SQLite)
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1)
## Изоляция (git worktree, ORCH-2)
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
## API
| Method | Path | Описание |
|--------|------|----------|
| GET | `/health` | health check |
| GET | `/status` | активные задачи (stage != done) |
| GET | `/queue` | очередь: counts + max_concurrency + последние jobs |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
## Деплой и эксплуатация
Топология, контейнеры, порты, env-карта, self-hosting риски — [docs/operations/INFRA.md](../operations/INFRA.md). Деплой-хук — [DEPLOY_HOOK.md](../operations/DEPLOY_HOOK.md). Staging — [STAGING.md](../operations/STAGING.md).
## ADR
Сквозные архитектурные решения — [adr/](adr/). Per-work-item решения — `docs/work-items/<id>/06-adr/`.
## Детали реализации
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-05 (main `f1b3146`). Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py.*

View File

@@ -0,0 +1,16 @@
# Architecture Decision Records
Индекс сквозных (cross-cutting) ADR проекта orchestrator.
Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-slug.md`.
| # | Решение | Статус | Дата | Источник |
|---|---------|--------|------|----------|
| adr-0001 | Реестр проектов (multi-repo) | accepted | 2026-06-02 | ORCH-6 |
| adr-0002 | Очередь задач вместо in-process потоков | accepted | 2026-06-03 | ORCH-1 |
| adr-0003 | Условный staging-гейт перед прод-деплоем | accepted | 2026-06-05 | ORCH-35 |
| adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 |
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
Принятый ADR не меняется — новое решение заводится отдельным файлом со ссылкой `supersedes adr-XXXX`.
Новые ADR добавляет архитектор при принятии решения (см. `CLAUDE.md` → Конвенции).

View File

@@ -0,0 +1,23 @@
# adr-0001: Реестр проектов (multi-repo)
- **Статус:** accepted
- **Дата:** 2026-06-02
- **Задача:** ORCH-6
## Контекст
Инцидент 2026-06-02: Plane-вебхук слушал весь воркспейс и хардкодил `repo = settings.default_repo` (enduro-trails). Задачи ЛЮБОГО проекта сливались в один репо с одним префиксом (ET). Нужна изоляция по проектам.
## Решение
Введён реестр `src/projects.py`: `ProjectConfig` (frozen dataclass) связывает `plane_project_id``repo` + `work_item_prefix` + `name`. Источник правды — env `ORCH_PROJECTS_JSON`; при пустом/невалидном — встроенный дефолт (`enduro-trails`/ET, `orchestrator`/ORCH). Позволяет: фильтровать вебхуки по проекту (неизвестный → ignore), резолвить gitea-репо + префикс, роутить Plane-синк в свой проект задачи.
## Альтернативы
- Один репо на всё — отклонён (источник инцидента).
- Хардкод маппинга в коде — отклонён в пользу env-конфигурируемого реестра с безопасным дефолтом.
## Последствия
- Изоляция проектов на уровне вебхуков и роутинга.
- Парсер устойчив: битый элемент скипается, пустой результат → дефолт.
- Основа для `is_self_hosting_repo` (adr-0003).
## Связи
adr-0003 (условный гейт опирается на repo из реестра).

View File

@@ -0,0 +1,23 @@
# adr-0002: Очередь задач вместо in-process потоков
- **Статус:** accepted
- **Дата:** 2026-06-03
- **Задача:** ORCH-1 (F-2b)
## Контекст
Ранняя версия запускала стадии конвейера в in-process daemon-потоках. Проблемы: не переживало рестарт (задачи терялись), нет контроля параллелизма, нет ретраев, нет наблюдаемости.
## Решение
Введена персистентная очередь задач (`src/queue_worker.py` + таблица `jobs` в SQLite): atomic claim задачи воркером, `max_concurrency`, ретраи при сбое, restart-safe (running-задачи реквестятся при старте), эндпоинт `GET /queue`.
## Альтернативы
- In-process потоки — отклонены (не restart-safe).
- Внешний брокер (Redis/RabbitMQ) — избыточно для текущего масштаба; SQLite-очередь проще и без новых зависимостей.
## Последствия
- Конвейер переживает рестарт контейнера.
- Контроль параллелизма и наблюдаемость через `/queue`.
- ⚠️ Очередь общая на все проекты прод-инстанса — фактор группового риска при self-hosting (см. `docs/operations/INFRA.md`).
## Связи
adr-0001 (реестр проектов), INFRA.md (общая очередь при self-hosting).

View File

@@ -0,0 +1,27 @@
# adr-0003: Условный staging-гейт перед прод-деплоем
- **Статус:** accepted
- **Дата:** 2026-06-05
- **Задача:** ORCH-35
## Контекст
Оркестратор дорабатывает сам себя (self-hosting). Раньше стадия `deploy` имела «бумажный» вердикт: deployer-агент писал `deploy_status: SUCCESS`, но реального прогона на изолированной среде не было. Нужен предохранитель: прод-деплой орка не должен происходить, пока изменения не проверены на живой staging-среде. При этом другие проекты (enduro-trails) staging-инфры не имеют.
## Решение
Добавлена промежуточная стадия `deploy-staging` между `testing` и `deploy`: `testing → deploy-staging → deploy → done`.
- deployer гоняет `scripts/staging_check.py --base-url http://localhost:8501` и пишет `staging_status: SUCCESS|FAILED` в `15-staging-log.md`.
- Quality Gate `check_staging_status` парсит вердикт (только YAML-frontmatter).
- **Гейт условный:** `is_self_hosting_repo(repo)` → реальная проверка только для `orchestrator`; для остальных проектов гейт = no-op `(True, "Staging gate N/A")`.
- FAILED → откат на `development`.
## Альтернативы
- Глобальный гейт для всех проектов — отклонён: у enduro нет staging-инстанса, задачи застревали бы на откате.
- Деплой реально дёргает хост-хук прямо здесь — отложен в ORCH-36 (Вариант B).
## Последствия
- Прод-деплой орка недостижим, пока staging-гейт не зелёный.
- Другие проекты не затронуты (no-op).
- Реальный docker-деплой через хук пока НЕ выполняется (вердикт «бумажный», но подкреплён прогоном сьюта). Исполняемый деплой — ORCH-36.
## Связи
adr-0001 (реестр проектов — основа `is_self_hosting_repo`), ORCH-34 (deploy-hook + rollback), ORCH-36 (исполняемый самодеплой).

View File

@@ -0,0 +1,45 @@
# adr-0004: Поллинг с ретраем в quality-gate check_ci_green (фикс CI-race)
- **Статус:** accepted
- **Дата:** 2026-06-05
- **Задача:** ORCH-045
## Контекст
Quality-gate `check_ci_green(repo, branch)` (`src/qg/checks.py`) проверяет combined commit-status ветки через Gitea API сразу после того, как developer-агент запушил код. Реализация была **single-shot**: один `GET /repos/{owner}/{repo}/commits/{branch}/status`, чтение `data["state"]``success` → пропуск, иначе → сразу `False`.
Это создавало race condition. Gitea-CI после пуша 13 секунды держит combined state `pending`, пока не отработают чек-раннеры. Если гейт опрашивал статус в этом окне, он получал `pending` и возвращал `False` **ровно один раз** — повторного опроса не было. Combined state затем дозеленевал до `success`, но гейт уже промахнулся, и задача застревала насмерть без видимой причины.
Реальный инцидент **ORCH-017**: гейт опросил статус в 17:58:54 → `pending`; CI дозеленел в 17:58:55. Задача встала в тупик (см. `docs/history` / lessons ORCH-017).
## Решение
`check_ci_green` превращён из single-shot в **polling с ретраем**:
- `state == "success"``(True, "CI green")` немедленно.
- `state in ("failure", "error")``(False, "CI state: <state>")` немедленно — CI красный, ретрай бессмыслен (терминальное состояние).
- `state == "pending"` (или `unknown` / иное не-терминальное) → `time.sleep(interval)` и опрос снова, до `N` попыток.
- После исчерпания всех попыток при всё ещё `pending``(False, "CI still pending after <T>s")`**явный** провал с причиной, чтобы оператор видел тупик, а не молчаливый стол.
- `404``(False, "Branch ... not found or no status")` — как раньше.
- Транзиентная `httpx.HTTPError` на отдельной попытке — **не падаем сразу**: логируем и пробуем ещё в рамках лимита попыток; если все попытки — сетевая ошибка → `(False, "API error: <e>")`.
Параметры вынесены в `src/config.py` (pydantic-settings, env-prefix `ORCH_`, единый стиль с остальными настройками):
- `ci_poll_max_attempts` (env `ORCH_CI_POLL_MAX_ATTEMPTS`, дефолт **12**)
- `ci_poll_interval_s` (env `ORCH_CI_POLL_INTERVAL_S`, дефолт **10**)
Итого по умолчанию гейт ждёт `pending` до ~2 минут (12 × 10s) перед тем как явно провалиться. Каждая не-финальная попытка логируется через существующий `logger` (`check_ci_green: attempt i/N, state=..., retrying in Ns`). `timeout=10` на каждый отдельный запрос сохранён.
Сигнатура `check_ci_green(repo, branch) -> tuple[bool, str]` **не менялась** — её зовёт stage_engine и реестр гейтов `QG_CHECKS`.
## Альтернативы
- **Оставить single-shot, опрашивать гейт повторно снаружи (на уровне stage_engine/воркера).** Отклонено: размазывает логику CI-ожидания по слоям, дублирует таймауты; гейт — естественное место знания о combined-status.
- **Webhook от Gitea на завершение CI вместо поллинга.** Отложено: требует надёжной доставки/дедупликации вебхуков именно по CI-статусу и переписывания триггера стадии; поллинг — минимальный, локализованный фикс race-а здесь и сейчас.
- **Бесконечный ретрай до зелёного.** Отклонено: задача могла бы висеть вечно при реально зависшем CI; ограниченный бюджет + явный `False` с причиной даёт оператору сигнал.
## Последствия
- CI-race ORCH-017 закрыт: транзиентный `pending` переживается ретраем, гейт не промахивается.
- `check_ci_green` теперь **блокирующий** до ~`max_attempts × interval` секунд при затяжном `pending` (по умолчанию ~2 мин). Это осознанный trade-off; для красного CI и success — выход немедленный, без задержки.
- Тупик больше не молчаливый: истечение попыток → `(False, "CI still pending after <T>s")`, причина видна.
- Бюджет/интервал настраиваемы через env без правки кода.
- `check_tests_passed` / `_parse_tests_verdict` (ORCH-47) **не затронуты**.
## Связи
ORCH-017 (инцидент-первоисточник: deadlock shared-gate из-за CI-race), реестр гейтов `QG_CHECKS` (`check_ci_green`), стадия `development`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.

View File

@@ -58,7 +58,8 @@ STAGE_TRANSITIONS = {
architecture: → development (agent: developer, QG: check_architecture_done)
development: → review (agent: reviewer, QG: check_tests_local)
review: → testing (agent: tester, QG: check_reviewer_verdict)
testing: → deploy (agent: deployer, QG: check_tests_passed)
testing: → deploy-staging (agent: deployer, QG: check_tests_passed)
deploy-staging: → deploy (agent: deployer, QG: check_staging_status)
deploy: → done (agent: None, QG: None)
}
```
@@ -189,8 +190,10 @@ services:
12. Gitea PR webhook: review event → QG check_review_approved → PASS
13. Advance: review → testing, tester launched
14. Tester: прогоняет тесты, пишет test-report.md → git push
15. Auto-advance: testing → deploy (QG check_tests_passed → PASS)
16. PR merge → Gitea PR webhook: action=closed, merged=true → done
15. Auto-advance: testing → deploy-staging (QG check_tests_passed → PASS)
16. Deployer: runs staging checks → writes 15-staging-log.md (staging_status: SUCCESS)
17. Auto-advance: deploy-staging → deploy (QG check_staging_status → PASS)
18. PR merge → Gitea PR webhook: action=closed, merged=true → done
```
### Review bounce path
@@ -323,6 +326,10 @@ jobs со статусом `running` (воркер умёр на рестарт
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
- `ORCH_QUEUE_POLL_INTERVAL` (default 2.0) — период опроса.
- `ORCH_AGENT_MODEL_DEFAULT` / `ORCH_AGENT_MODEL_<AGENT>` (ORCH-41) — модель агентов; дефолт `claude-opus-4-8`.
- `ORCH_AGENT_EFFORT_DEFAULT` / `ORCH_AGENT_EFFORT_<AGENT>` (ORCH-41) — режим `--effort` (low|medium|high|xhigh|max).
- `ORCH_AGENT_FALLBACK_MODEL` (ORCH-41) — опц. `--fallback-model` при overloaded.
- per-project override: `agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`; резолверы `resolve_agent_model` / `resolve_agent_effort` (project > per-agent env > default > пусто).
Наблюдаемость: `GET /queue` — counts по статусам + последние 10 jobs.

View File

@@ -0,0 +1,128 @@
# Lessons Learned — 2026-06-05 (вечер): ORCH-17/45/47 + деплой прода
## Итог дня
Закрыты три задачи (ORCH-17, ORCH-45, ORCH-47), два прод-гейта стали умнее, заведено
4 системных задачи в бэклог (ORCH-44/46/48 + B6). Главный сквозной урок: **конвейер не мог
провести эти задачи автономно из-за дыр в самом конвейере** — потребовались ручные merge и
ребилды прода. Корни задокументированы, чинятся отдельными задачами.
---
## 1. ORCH-17 — approve-ping links (закрыта вручную)
Подробный разбор: `docs/history/LESSONS_ORCH-017.md`. Кратко: косметика (2 ссылки)
застряла 5 раз, объективный дедлок shared-гейта, ручной merge PR #37 (`26c6f267`).
---
## 2. ORCH-45 — CI-гонка в `check_ci_green` (исправлена, в проде)
### Проблема
`check_ci_green` делал **один** запрос статуса CI сразу после developer. Если CI ещё
`pending` 1-3 секунды (реальный кейс: опрос 17:58:54 → pending, CI позеленел 17:58:55) —
гейт возвращал False **один раз** и задача застревала насмерть с зелёным CI.
### Решение (PR #39, merge `982698c4`)
Поллинг с ретраем: `success`/`failure` — терминальны (сразу), `pending` → ждать
`CI_POLL_INTERVAL_S`(10с) до `CI_POLL_MAX_ATTEMPTS`(12) раз, истёк лимит → явный
`False` с причиной "CI still pending after Ns" (не виснет молча). Параметры в `config.py`
как env `ORCH_CI_POLL_*`. ADR-0004. +5 тестов (мок httpx + time.sleep).
---
## 3. ORCH-47 — тестер-гейт игнорил `result:` (исправлен, в проде)
### Проблема (уловка-22)
`check_tests_passed`/`_parse_tests_verdict` читал только `verdict:`/`status:` из frontmatter
`13-test-report.md`, но промпт tester-агента велит писать `result: PASS|FAIL`. Честный тестер
(`result: PASS`, без `verdict:`) → гейт «No machine-readable verdict» → ложный FAIL → петля
dev↔review↔tester → Blocked. **И сама ORCH-47 (которая это чинит) попала в тот же капкан:**
в проде крутился старый гейт → не понимал её собственный `result: PASS` → 3 круга петли.
Змея кусает хвост: чтобы пройти гейт автономно, фикс уже должен быть в проде.
### Решение (PR #40, merge `5d04de9e`)
`result:` добавлен как равноправное поле наряду с `verdict:`/`status:`. Любое одно непустое
поле достаточно. Negative-токен (BLOCKED/FAILED) в ЛЮБОМ поле авторитетен (ET-013 кейс
сохранён). Token sets заморожены для обратной совместимости. ADR-001. +6 тестов (68 passed).
После деплоя ручной `advance_stage` пнул застрявшую task → гейт принял `result: PASS`
прошёл testing. Петля исчезла навсегда.
### Остаточная находка → B6 / ORCH-48
На staging деплоер дал 9/10 PASS, завалил **B6 Registry isolation**: staging-реестр видит
боевые ET+ORCH вместо одного sandbox (нарушает «staging — только sandbox»). Деплоер честно
поставил FAILED и НЕ стал натягивать зелёнку (вне мандата) → откат by design. К фиксу гейта
отношения не имеет (E2E против sandbox прошёл). Заведена ORCH-48.
---
## 4. ДЕПЛОЙ ПРОДА — как правильно (важная операционная памятка)
### `/app` запечён в образ, НЕ volume
`docker-compose.yml`: `build: .` + `COPY src/ ./src/`. Поэтому `git pull` + рестарт с
`--no-build` **НЕ довозит код** — нужен `docker compose build orchestrator`. Деплой-хук
(`scripts/orchestrator-deploy-hook.sh`) по дефолту целит в **staging** (by design) — для
прода нужны env `TARGET_SERVICE=orchestrator TARGET_PORT=8500 COMPOSE_PROFILE=''`.
### Порты/профили
- prod orchestrator = порт **8500** (`/health``{"status":"ok"}`), `network_mode: host`,
профиль prod = пустой (стартует обычным `docker compose up -d orchestrator`).
- staging = порт **8501**, профиль `staging` (стартует только `--profile staging`).
### Рабочая последовательность деплоя (проверена дважды 05.06)
1. `sudo chown -R slin:slin /home/slin/repos/orchestrator` (см. грабля ниже).
2. `git checkout main && git reset --hard origin/main && git clean -fd -e '*.bak*' -e '.deploy-prev-image-prod'`.
3. `docker compose build orchestrator`.
4. `docker compose up -d orchestrator` + health-loop на :8500.
5. **Проверка claude-auth** (ребилд её ломает — см. ниже).
6. Проверка что новый код активен в `/app` (grep маркера правки).
### ⚠️ ГРАБЛЯ: хост-репо рассинхронизирован с git (агенты пишут под root)
Хост-репо `/home/slin/repos/orchestrator` оказывался на feature-ветке (не main), а рабочая
копия засеяна untracked+modified файлами, созданными агентами **под uid=0 (root-owned)** прямо
в репо. → `git pull --ff-only` падал `Permission denied` / `would be overwritten`, обычный
`rm` под slin не мог снести root-файлы. **Лечение:** `sudo chown -R slin:slin <repo>`
проверить что modified=совпадает-с-main и untracked=уже-в-main (дубликаты, не теряем) →
`git reset --hard origin/main` + `git clean`. **Хук это НЕ разруливает** — сверять состояние
хост-репо перед каждым деплоем.
### ⚠️ ГРАБЛЯ: ребилд ломает claude-auth (проверять ВСЕГДА)
Пересоздание контейнера может root-овнить `/home/slin/.claude/.credentials.json` и сделать
`/root/.claude` пустышкой → агенты падают `Not logged in`. Защита — монтирование creds в
compose (`/home/slin/.claude` + `.claude.json`), launcher форсит `HOME=/home/slin`.
**После каждого ребилда боевая проверка:**
`docker exec orchestrator bash -c 'cd /tmp && HOME=/home/slin /opt/claude-code/bin/claude.exe --print "ОК"'`
(timeout 90с). 05.06 auth пережил оба ребилда — защита держит.
---
## 5. ЗАПУСК конвейера и Gitea API
### Старт конвейера = Plane Backlog → In Progress
Конвейер стартует штатно переводом задачи в Plane из Backlog в **In Progress** (код:
`webhooks/plane.py handle_status_start` — «pipeline is started when Slava moves the issue
into In Progress»). Webhook создаёт task-row, заводит ветку, запускает analyst. Никаких
ручных вставок в БД.
### QG-0: лимит заголовка 80 символов
При старте задача с заголовком >80 символов заворачивается на QG-0 («Title слишком длинный»)
и уходит в Blocked. Чинить — укоротить `name` (суть в заголовок, детали в description),
вернуть в Backlog, снова In Progress.
### Gitea API грабли
- **merge/create PR** требуют заголовок `Authorization: token <ORCH_GITEA_TOKEN>` (форма
с префиксом `token `), иначе 401 "token is required".
- **heredoc через ssh+docker exec глотает вывод** python-скрипта. Надёжный путь: написать
`.py` локально → `base64 -w0``ssh "echo <b64> | base64 -d > /tmp/x.py"``docker cp`
`docker exec python3 /tmp/x.py`. Это же обходит экранирование кириллицы/скобок.
---
## Состояние прод-гейтов после 05.06
-`check_ci_green` — поллинг с ретраем (ORCH-45)
-`check_tests_passed` — читает `result:`/`verdict:`/`status:` (ORCH-47)
## Бэклог (high) после дня
- **ORCH-44** — надёжность запуска агента (preflight слеп к auth; `--effort` гасит вывод;
пустой run-лог → должен быть failed).
- **ORCH-46** — «испорченный телефон»: орк не передаёт деву ТЕКСТ замечаний reviewer/tester
(только ссылку на файл), противоречивые сигналы tester↔reviewer, нет памяти между кругами.
- **ORCH-48 / B6** — staging registry isolation (staging видит прод-проекты вместо sandbox).

View File

@@ -0,0 +1,103 @@
# Lessons Learned — ORCH-017 (Telegram approve-ping links)
## Дата: 2026-06-05
## Задача: ORCH-017 — Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве BRD
## Итог: смержено **вручную** (PR #37, merge `26c6f267`) после ~5 застреваний конвейера
---
## TL;DR
Косметическая задача (две HTML-ссылки в уведомлении) **5 раз застряла** в конвейере и каждый
раз требовала ручного пинка. Корень — **не баг задачи, а дыры автономности конвейера**. Код был
готов и зелёный (434 теста), но пайплайн не мог довести его до merge сам. В итоге — ручной merge
Owner-ом; четыре системные дыры заведены в бэклог (ORCH-44/45/46/47).
---
## Хронология застреваний
1. **Auth claude после rebuild** — агенты падали `Not logged in` (root-owned creds + `/root/.claude`
пустышка). См. отдельный разбор в memory/INCIDENT по auth. → починено + защищено монтированием.
2. **`check_ci_green` race** — гейт опросил CI **один раз** в `17:58:54``pending`; CI дозеленел в
`17:58:55` (промах на 1 секунду). Повторного опроса нет → задача висит насмерть с зелёным CI.
3. **Петля dev↔review↔testing → `max retries reached`** (MAX_DEVELOPER_RETRIES=3).
4. **Откат неполный** — убрали код shared-гейта, но оставили 2 doc-строки про него → рассинхрон
код↔доки → reviewer снова REQUEST_CHANGES.
5. **Объективный дедлок** (см. ниже) → ручной merge.
---
## Корневые проблемы (→ бэклог)
### P1. `check_ci_green` промахивается на гонке CI (→ ORCH-45)
Гейт читает статус CI **ровно один раз** сразу после developer. Если CI ещё `pending` — задача
застревает молча, без повторного опроса. Нужен polling с ретраем: `pending` → ждать N×15с,
`success` → advance, `failure` → rollback, вечный `pending` → уведомить (не застревать молча).
### P2. Developer не понимает замечаний reviewer/tester — "испорченный телефон" (→ ORCH-46)
**Это прямой удар по автономности.** Три причины, почему dev повторял одну и ту же ошибку:
- **Испорченный телефон.** При REQUEST_CHANGES `stage_engine.py:~421` шлёт developer-у только
`"Fix findings in docs/work-items/<WI>/12-review.md"`**без текста претензий**, лишь ссылку на
файл. Ключевую governance-мысль легко проскочить. → Вклеивать ТЕКСТ findings прямо в task_desc.
- **Противоречивые сигналы.** После tester прилетает `"Tests FAILED. Fix failures"` (толкает чинить
связанное с тестами → dev лез в test-gate). После reviewer — `"не трогай gate"`. Два
противоположных приказа. → Склеивать замечания tester+reviewer в одно непротиворечивое ТЗ.
- **Нет памяти между кругами.** Каждый запуск developer — новый чистый агент, не помнит прошлых
заворотов. Видит "тесты падают" → снова лезет в gate. → Передавать историю прошлых REQUEST_CHANGES/
FAIL ("на чём уже погорел, чего НЕ делать"). Можно: ранняя эскалация к Owner при повторе.
### P3. `check_tests_passed` игнорирует поле `result:` (→ ORCH-47)
`_parse_tests_verdict` (`src/qg/checks.py`) читал только `verdict:`/`status:` из frontmatter
`13-test-report.md`. НО промпт tester-агента (`.openclaw/agents/tester*`) предписывает писать
`result: PASS | FAIL`. Честный тестер (отчёт ORCH-017: `result: PASS`, без `verdict:`/`status:`)
проваливал гейт ложным «Tests FAILED» → откат на development. ORCH-016 проходил лишь потому, что
дублировал `verdict:` И `result:`. → Гейт должен читать `result:` как первоклассное машинное поле.
**ВАЖНО:** это shared-гейт (влияет на ВСЕ проекты общего прода) → требует отдельного ADR
(CLAUDE.md правило 2), потому вынесено в свой work item, не в ORCH-017.
### P4. Preflight слеп к auth и битым флагам (→ ORCH-44)
`claude --version` отвечает даже без логина → preflight=ok, а реальный запуск падает `Not logged in`.
Плюс `--effort` с CLI 2.1.142 + `--print`/`--output-format json` гасит вывод. Нужны: дешёвая
проверка auth без токенов (права+дата истечения OAuth в `.credentials.json`), фикс effort,
«пустой лог + job running + процесс мёртв → failed».
---
## Главный урок: объективный дедлок shared-инфры
ORCH-017 попала в **неразрешимый автономно дедлок** из-за того, что тест-отчёт уже написан под
новый контракт (`result: PASS`):
- **С фиксом гейта в ветке** → reviewer заворачивает (governance: shared-инфра без ADR). ❌
- **Без фикса гейта** → `check_tests_passed` не видит `result:` → ложный FAIL → откат. ❌
**Вывод:** изменение shared quality-gate нельзя протаскивать внутри прикладной задачи. Оно создаёт
циклическую зависимость (артефакты задачи зависят от изменённого гейта, а гейт нельзя менять без
отдельного ADR). Менять shared-гейты — только отдельным work item со своим ADR. Если артефакты уже
написаны под новый контракт — задача физически не пройдёт, пока не приедет фикс гейта.
---
## Урок про роль ассистента/оператора
Когда оператор **раз за разом пинает гейты и чистит за dev вручную** — это сигнал «конвейер не тянет
автономно». Честнее предложить Owner-у ручной merge/эскалацию, чем гонять карусель кругов и доказывать
конвейеру то, что уже готово (код зелёный, reviewer: «технически корректно», претензии процедурные).
---
## Урок про откат
При откате **кода** обязательно откатывать и **доки/CHANGELOG**, иначе возникает обратный
рассинхрон код↔доки (доки описывают фичу, которой в коде уже нет) → reviewer заворачивает. Откат —
это код + доки + changelog + (при необходимости) тест-отчёт одним согласованным движением.
---
## Что сработало хорошо
- **Reviewer ловит governance-нарушения** — корректно завернул протаскивание shared-гейта в
прикладную задачу. Процедурно прав, даже когда код технически верный.
- **Безопасный ручной пинок гейта** через `stage_engine.advance_stage(...)` — без ребилда/мержа,
перевызывает QG внутри процесса орка.
- **Ручной merge как осознанный выход** из дедлока (с явным ОК Owner), а не бесконечные круги.

View File

@@ -0,0 +1,119 @@
# LESSONS — ORCH-048 (B6 staging registry isolation, вариант «в»)
**Дата:** 2026-06-06
**Work item:** ORCH-048 — «staging B6 check reads registry from host worktree, not staging container»
**Статус:** ✅ Done. Merge PR #45 (`2a36ed80`), Plane → Done, task 38 → done. Прод не тронут.
---
## TL;DR
B6-чек staging-suite давал **ложный FAIL** (`prod-ET=YES, prod-ORCH=YES`), блокируя `deploy-staging` у **всех** ORCH-задач, хотя изоляция реестра в staging работала корректно. Починили, выбрав архитектурный вариант, который **не порождает новых ловушек автономности**. По дороге словили три урока, которые стоят дороже самой фичи.
---
## 1. Root cause (для истории)
`scripts/staging_check.py` блок **B6** был единственным чеком suite, который не ходил по HTTP к живому инстансу, а **импортировал Python-код локально**:
```python
sys.path.insert(0, "/repos/orchestrator") # host-worktree
importlib.reload(sys.modules["src.projects"]) # подхватывает env ТЕКУЩЕГО процесса
known = known_plane_project_ids()
```
Деплоер запускал suite **с хоста**, где `ORCH_PROJECTS_JSON` не задан → `src.projects` грузил встроенный `_DEFAULT_PROJECTS` (ET+ORCH) → `known_plane_project_ids()` возвращал боевые id → **ложный FAIL**. То есть B6 проверял реестр НЕ того окружения, реестр которого реально использует staging-инстанс.
Изоляция при этом была исправна: внутри `orchestrator-staging` `known_plane_project_ids()` корректно отдавал только sandbox (`.env.staging`).
---
## 2. ГЛАВНЫЙ УРОК: «курица-яйцо» в staging-гейте
Архитектор на первом прогоне выбрал **вариант (а): новый HTTP-эндпоинт `GET /projects`**, и B6 стал ходить на него. Решение красивое (единый HTTP-стиль с остальными чеками), **но оно само себя заблокировало**:
- B6 проверяет **работающий** staging-инстанс (порт 8501).
- Эндпоинт `/projects` **запечён в Docker-образ** (`src/main.py`).
- В текущем (ещё не пересобранном) образе эндпоинта НЕТ`GET /projects`**404** → B6 FAIL → откат на development.
- Чтобы чек прошёл, нужен **ручной bootstrap-деплой** образа. А деплой не происходит, потому что чек красный. **Тупик by design.**
Подтверждено на проде: `GET /projects` на 8501 и 8500 → 404 → `deploy-staging FAILED`.
**Вывод-правило:**
> Staging-чек НЕ должен проверять то, что появляется в работающем инстансе только ПОСЛЕ деплоя проверяемой ветки. Иначе первый прогон всегда падает и требует ручного bootstrap — это прямая поломка автономности.
**Решение — вариант (в):** запускать suite **ВНУТРИ** staging-контейнера (`docker exec orchestrator-staging`), читать реестр из собственного process-env контейнера, убрать host-path хак. Преимущество принципиальное:
- B6 не зависит от того, что отдаёт инстанс по HTTP.
- `staging_check.py` берётся из bind-mount → свежий код подхватывается **без ребилда образа**.
- **Курицы-яйца нет ни на первом прогоне, ни в будущем.**
Вариант (б) (`docker exec ... python3 -c "..."` + парсинг stdout) отклонён: хрупкое экранирование (см. `LESSONS_2026-06-05.md`).
**Как это попало в реализацию:** после FAIL под (а) — откатили ветку к analyst-артефактам (`git reset --hard <analyst-commit>`), стёрли ADR(а)+код(а), зашили в `02-trz.md §4` блок «РЕШЕНИЕ ПРИНЯТО ВЛАДЕЛЬЦЕМ: вариант (в)» с обоснованием и чек-листом, откатили задачу на `architecture` + поставили job архитектору заново. Второй прогон: arch→dev→review→tester→deploy-staging — без петель, **B6 ✓ PASS, 10/10**.
---
## 3. УРОК: орк мержит в main ТОЛЬКО логи, а не фикс-код
После прохождения staging орк сам:
- закрыл задачу в `done`,
- смержил в `main` PR с **логами** (`15-staging-log.md`, `14-deploy-log.md`),
- но **сам фикс-код остался в feature-ветке**`main` всё ещё содержал старый сломанный B6.
Это by design: фичу в main вливает **владелец**. Поймали проверкой:
```bash
git fetch origin -q
git log --oneline origin/main..origin/feature/<branch> # покажет невлитые коммиты фикса
git show origin/main:scripts/staging_check.py | grep -c '_evaluate_b6' # 0 = фикс НЕ в main
```
**Правило:**
> Прежде чем считать задачу реально доставленной — проверить `git log origin/main..feature` и наличие ключевой функции/строки фикса в `origin/main`. `done` в Plane + смерженные логи ≠ код в main.
Финальный шаг: смерджить feature-PR в main (Gitea API, `Do: merge`), затем синхронизировать host-репо.
---
## 4. УРОК: rollout bind-mount-фикса = host `git pull`, без ребилда/рестарта прода
ORCH-048 менял только **bind-mounted / non-runtime** артефакты:
| Файл | Как доходит до прода |
|------|----------------------|
| `scripts/staging_check.py` | bind-mount (`/home/slin/repos``/repos`); **не** в образе (`scripts/` нет в `/app`) → host `git pull` → live сразу |
| `.openclaw/agents/deployer.md` | bind-mounted промпт, читается при запуске агента → live на следующем запуске |
| `tests/`, `docs/` | не деплоятся |
`src/` и `Dockerfile` НЕ менялись → **рестарт/ребилд прод-контейнера 8500 не нужен и не делался** (zero group-risk для ET).
**Грабли host-репо:** `git pull` в `/home/slin/repos/orchestrator` сначала упёрся в `sudo: a password is required` — ложная тревога. Репо принадлежит `slin`, sudo не нужен; прямой `git pull --ff-only origin main` прошёл. **Сначала проверь `ls -ld` / `stat -c %U` репо — не лезь в sudo вслепую.**
**Верификация rollout в живом bind-mount (обязательна):**
```bash
grep -c '_evaluate_b6' scripts/staging_check.py # >=1
grep -c 'sys.path.insert(0, "/repos/orchestrator")' scripts/staging_check.py # 0
grep -c 'docker exec orchestrator-staging' .openclaw/agents/deployer.md # >=1
curl -s -o /dev/null -w '%{http_code}' http://localhost:8500/health # 200
```
---
## 5. Технические заметки (gotchas)
- **В контейнере orchestrator НЕТ `curl`** — для Gitea/Plane API использовать `urllib` через python (script-file → base64 → `docker cp``docker exec`).
- **Plane state-id зависят от проекта.** Approved для проекта orchestrator = `63f2c8fe-dcda-4ace-952f-dd88bd0118ff` (НЕ дефолтный `a519a341...` из кода — тот для sandbox/ET). Брать реальные state-id через `GET .../states/`.
- **BRD-апрув = перевод Plane-issue в статус Approved** → webhook ловит смену статуса → путь `agent=None``approved-via-status` → гейт пропускает, БЕЗ повторного запуска `check_analysis_approved`.
- **Dockerfile НЕ копирует `scripts/`** в образ — `staging_check.py` доступен в контейнере только через mount. Путь запуска внутри контейнера учитывать (не `/app/scripts`).
- **Перезапуск стадии вручную:** `update_task_stage(task_id, "<stage>")` + `enqueue_job(agent, repo, task_content, task_id)`. Guard перед этим: `agent_running IS NULL` И нет jobs со `status IN ('queued','running')` для task_id.
---
## 6. Итог по гейтам/ядру после серии ORCH-45/46/47/48
-`check_ci_green` — поллинг (ORCH-45)
-`check_tests_passed` — читает `result:` (ORCH-47)
-`stage_engine` — передаёт деву **текст** findings, не только ссылку (ORCH-46)
- ✅ B6 staging — читает реестр ВНУТРИ staging-контейнера, больше не ложный FAIL (ORCH-48) → **deploy-staging разблокирован для всех ORCH-задач**
Конвейер стал по-настоящему автономным: задача проходит analyst→deploy без ручного пинания стадий.

View File

@@ -0,0 +1,90 @@
# Orchestrator Deploy Hook
`scripts/orchestrator-deploy-hook.sh` — хост-скрипт деплоя orchestrator с health-чеком и авто-rollback.
## Как работает
### Режим `--deploy` (по умолчанию)
1. **Захват текущего образа** — до рестарта записывает ID образа работающего контейнера в `$PREV_IMAGE_FILE` (best-effort, не падает если сервис не запущен).
2. **git pull** — обновляет код репозитория.
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".
- **Провал** → авто-rollback (шаг 5).
5. **Авто-rollback** — восстанавливает образ из `$PREV_IMAGE_FILE`, рестарт, повторный health 5×3с.
- Если восстановился → `exit 1` (деплой провалился, откат успешен).
- Если и откат не помог → `exit 2` (критично).
### Режим `--rollback`
Вручную откатывает сервис на предыдущий образ из `$PREV_IMAGE_FILE`.
## Переменные окружения
| Переменная | Дефолт | Описание |
|------------------|-----------------------------------|-----------------------------------------------|
| `TARGET_SERVICE` | `orchestrator-staging` | Имя docker-compose сервиса |
| `TARGET_PORT` | `8501` | Порт health-check |
| `TARGET_IMAGE` | `orchestrator-orchestrator-staging` | Имя образа для retag при rollback |
| `COMPOSE_PROFILE`| `staging` | Docker compose profile (пусто = без профиля) |
| `PREV_IMAGE_FILE`| `$REPO/.deploy-prev-image-staging`| Файл для сохранения предыдущего образа |
| `LOG` | `/var/log/orchestrator/deploy-hook.log` | Лог-файл (fallback: `$REPO/deploy-hook.log`) |
> ⚠️ **Дефолт — всегда STAGING**. Прод активируется только явным переопределением env.
## Примеры запуска
### Staging (дефолт, безопасно)
```bash
cd /home/slin/repos/orchestrator
bash scripts/orchestrator-deploy-hook.sh --deploy
# или просто:
bash scripts/orchestrator-deploy-hook.sh
```
### Прод (осознанный шаг, Этап 5)
```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
```
### Ручной rollback staging
```bash
bash scripts/orchestrator-deploy-hook.sh --rollback
```
## Коды выхода
| Код | Значение |
|-----|------------------------------------------------------|
| `0` | Деплой успешен, сервис здоров |
| `1` | Деплой провалился; откат выполнен (или пропущен) |
| `2` | Деплой провалился И откат тоже провалился (критично) |
## Логи
```
/var/log/orchestrator/deploy-hook.log
```
Каждая строка с UTC-таймстампом в формате `[2026-06-05T06:30:00Z]`.
## Разница с enduro-deploy-hook.sh
| Функция | enduro-deploy-hook.sh | orchestrator-deploy-hook.sh |
|----------------------|-----------------------|-----------------------------|
| Захват PREV_IMG | ✅ | ✅ |
| git pull | ✅ | ✅ |
| Рестарт | ✅ | ✅ |
| Health-цикл (60с) | ❌ | ✅ 10×6с |
| Авто-rollback | ❌ | ✅ |
| Параметризация (env) | ❌ хардкод | ✅ дефолт=staging |
| Compose profile | ❌ | ✅ --profile staging |

122
docs/operations/INFRA.md Normal file
View File

@@ -0,0 +1,122 @@
# INFRA.md — инфраструктура и эксплуатация оркестратора
> RUNBOOK. Топология, контейнеры, порты, переменные окружения, границы.
> **Секреты тут НЕ хранятся** — только дескрипторы. Реальные значения — в `.env` на хосте.
## Топология
```
host: mva154 (slin@82.22.50.71), network_mode: host
┌──────────────────────────────────────────────────────────────────────┐
│ orchestrator (PROD) :8500 env_file .env │
│ БД: ./data/orchestrator.db (обслуживает ВСЕ прод-проекты) │
│ │
│ orchestrator-staging (STAGING) :8501 env_file .env.staging │
│ БД: ./data/staging/orchestrator.db (изолирована, только sandbox) │
│ profile: staging — НЕ стартует обычным `docker compose up` │
└──────────────────────────────────────────────────────────────────────┘
│ webhooks │ git
▼ ▼
Plane (ag_proj) Gitea (localhost:3000)
/repos/<project> ← общий каталог репозиториев (host: /home/slin/repos)
```
## Контейнеры
| Контейнер | Роль | Порт | env_file | БД (хост) | Старт |
|-----------|------|------|----------|-----------|-------|
| `orchestrator` | прод | 8500 | `.env` | `./data/orchestrator.db` | `docker compose up -d` |
| `orchestrator-staging` | staging / песочница | 8501 | `.env.staging` | `./data/staging/orchestrator.db` | `docker compose --profile staging up -d orchestrator-staging` |
Оба: `network_mode: host`, `init: true` (tini как PID 1 — reaping зомби, B-2), `restart: unless-stopped`.
### Тома (volumes)
- `./data``/app/data` (БД; у staging — `./data/staging`)
- `/home/slin/repos``/repos` (рабочие репозитории проектов)
- `/var/run/docker.sock` (для docker-операций деплоя)
- claude-code, node, `~/.claude*` (CLI агентов, ro)
- `~/.orchestrator-ssh``/root/.ssh` (ro, деплой по ssh)
## Переменные окружения (карта; значения — в `.env`)
| Переменная | Назначение |
|-----------|-----------|
| `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API |
| `ORCH_PLANE_WEB_URL` | внешний (браузерный) web-URL Plane для кликабельных ссылок на issue в уведомлениях (ORCH-017); пусто → фолбэк на `ORCH_PLANE_API_URL`, loopback-фолбэк → ссылка опускается |
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane |
| `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC |
| `ORCH_CLAUDE_BIN` | путь к claude CLI |
| `ORCH_REPOS_DIR` / `ORCH_HOST_REPOS_DIR` | каталог репозиториев (в контейнере / на хосте) |
| `ORCH_DB_PATH` | путь к SQLite БД |
| `ORCH_PROJECTS_JSON` | реестр проектов (Plane id → repo + prefix); пусто → дефолт из `src/projects.py` |
| `ORCH_AGENT_MODEL_DEFAULT` | LLM-модель агентов по умолчанию (ORCH-41); дефолт `claude-opus-4-8` |
| `ORCH_AGENT_MODEL_<AGENT>` | per-agent модель (ANALYST/ARCHITECT/DEVELOPER/REVIEWER/TESTER/DEPLOYER); пусто → default |
| `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`); пусто → без флага |
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
## Реестр проектов (`src/projects.py`, ORCH-6)
Связывает Plane project id → gitea repo + work-item prefix. Источник: `ORCH_PROJECTS_JSON`, fallback — встроенный дефолт. Прод видит: `enduro-trails` (ET), `orchestrator` (ORCH). Staging видит ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция.
## Модель и effort агентов (`src/config.py` + `src/agents/launcher.py`, ORCH-41)
Модель LLM и режим работы (`--effort`) каждого агента **конфигурируемы** — глобально per-agent (env) и per-project (через `ORCH_PROJECTS_JSON`).
**Приоритет резолвинга** (`resolve_agent_model` / `resolve_agent_effort`):
1. per-project override — `agent_models` / `agent_efforts` в записи `ORCH_PROJECTS_JSON`;
2. per-agent env — `ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>` (если непусто);
3. глобальный дефолт — `ORCH_AGENT_MODEL_DEFAULT` (`claude-opus-4-8`) / `ORCH_AGENT_EFFORT_DEFAULT` (`high`);
4. пусто → флаг не передаётся, действует дефолт CLI.
**Значения effort:** `low` < `medium` < `high` < `xhigh` < `max` — рычаг «качество vs стоимость/время». Дефолтная раскладка: думающие агенты (analyst/architect/developer/reviewer) → `high`, механические (tester/deployer) → `medium`. Невалидное значение → лог-warning, флаг опускается.
**Per-project override в `ORCH_PROJECTS_JSON`** (поля `agent_models` / `agent_efforts` опциональны, старые записи работают):
```json
{"plane_project_id":"...","repo":"orchestrator","work_item_prefix":"ORCH",
"agent_models":{"developer":"claude-opus-4-8","reviewer":"claude-sonnet-4-6"},
"agent_efforts":{"developer":"xhigh","tester":"low"}}
```
> ⚠️ Бюджет (ORCH-38): `claude-opus-4-8` дефолт в коде; реальное переключение прод-env делается отдельно после согласования.
## ⚠️ Self-hosting — оркестратор дорабатывает САМ СЕБЯ
**Факт:** прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты (enduro-trails + orchestrator), с ОБЩЕЙ БД `./data/orchestrator.db` и общей очередью задач (ORCH-1).
**Следствие — групповой риск:** когда орк выполняет задачу из проекта ORCH (дорабатывает себя), он бежит в том же инстансе, что обслуживает enduro-trails.
- Рестарт / падение прод-контейнера орк-задачей → конвейер ВСЕХ проектов встаёт.
- Кривой self-деплой (ORCH-36, Вариант B) → лежат все проекты сразу.
- Общая очередь → орк-задача занимает concurrency-слоты других проектов.
**Что изолировано (безопасно):**
- Staging (8501) — отдельная БД (`./data/staging`), отдельный реестр (`ORCH_PROJECTS_JSON` = только sandbox). Прод-проекты не видит.
- Репозитории разделены, изоляция веток через git worktree (ORCH-2).
**Страховки:**
- Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op.
**Правила для агентов при задачах ORCH:**
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.
2. Все проверки деплоя — на staging (8501), боевой 8500 не трогать.
3. Деплой self — только через хук с health-check + авто-rollback (`DEPLOY_HOOK.md`).
## Эксплуатация (быстрые команды)
```bash
# статус
docker ps --filter name=orchestrator
curl -s http://localhost:8500/health
curl -s http://localhost:8500/status # активные задачи
curl -s http://localhost:8500/queue # очередь
# поднять staging-песочницу
docker compose --profile staging up -d orchestrator-staging
curl -s http://localhost:8501/health
# логи
docker logs --tail 100 orchestrator
```
---
*RUNBOOK 2026-06-05. Обновлять при изменении топологии/портов/переменных. См. CONTRIBUTING.md §8.*

View File

@@ -0,0 +1,85 @@
# Staging Environment (ORCH-31)
Orchestrator supports a permanent **staging instance** running on port **8501** with a
fully-isolated SQLite database. The staging instance shares the same codebase and
Dockerfile as production but is started under the `staging` Docker Compose profile so it
**never starts accidentally** during a normal `docker compose up -d`.
## Architecture
| | Production | Staging |
|---|---|---|
| Port | 8500 | 8501 |
| Container name | `orchestrator` | `orchestrator-staging` |
| DB (host path) | `./data/orchestrator.db` | `./data/staging/orchestrator.db` |
| DB (container path) | `/app/data/orchestrator.db` | `/app/data/orchestrator.db` |
| env file | `.env` | `.env.staging` |
| Compose profile | *(default)* | `staging` |
DB isolation is achieved via a separate volume mount (`./data/staging:/app/data`), not by
changing `ORCH_DB_PATH` — the container path stays identical while the host path is a
different directory.
## Prerequisites
1. **`.env.staging`** — create from the template (see below). This file is **not committed**
to the repo (it contains secrets). Copy and fill in values before first start.
2. **`./data/staging/`** directory — created automatically on first container start.
### Create `.env.staging`
```bash
cd /home/slin/repos/orchestrator
cp .env.staging.example .env.staging
# Edit .env.staging — fill in real tokens / secrets.
# At Stage 1 (ORCH-31) you can reuse prod values; sandbox Plane project
# and isolated Gitea webhook will be wired in ORCH-32.
nano .env.staging
```
## Starting Staging
```bash
cd /home/slin/repos/orchestrator
docker compose --profile staging up -d orchestrator-staging
```
Check it is running:
```bash
docker ps | grep orchestrator-staging
curl -s http://localhost:8501/health | python3 -m json.tool
```
## Stopping Staging
```bash
docker compose --profile staging stop orchestrator-staging
# or remove the container entirely:
docker compose --profile staging down orchestrator-staging
```
## Normal `up -d` does NOT start staging
```bash
# This starts ONLY the prod orchestrator (port 8500). Staging is NOT affected.
docker compose up -d
```
The `profiles: [staging]` directive in `docker-compose.yml` ensures staging is
completely invisible to commands that do not pass `--profile staging`.
## Logs
```bash
docker logs -f orchestrator-staging
```
## Roadmap
| Task | Description |
|---|---|
| **ORCH-31** *(this PR)* | Infra: compose service, .env template, gitignore, docs |
| **ORCH-32** | Sandbox: isolated Plane project + Gitea repo for staging |
| **ORCH-33** | Test suite running against staging endpoint |
| **ORCH-34** | Deploy hook: promote `orchestrator:candidate` image to staging |

View File

@@ -0,0 +1,155 @@
# STAGING_CHECK.md — Инструкция по запуску staging check suite (ORCH-33)
## Что это
`scripts/staging_check.py` — самостоятельный скрипт проверки **живого** staging-стенда orchestrator (порт 8501). Не unit-тесты — реальные HTTP-вызовы против работающих сервисов.
Три блока проверок:
| Блок | Название | Что проверяет |
|------|----------|---------------|
| A | SMOKE | `/health`, `/queue`, `ORCH_STAGING=true` |
| B | ACCESS | Plane sandbox (R), Gitea sandbox (R+push), реестр проектов |
| C | E2E | Создать задачу → триггер конвейера → ветка + коммент → cleanup |
Exit code: **0** = все PASS, **non-zero** = есть FAIL.
---
## Требования к окружению
Скрипт читает токены/URL из env (те же переменные, что использует orchestrator):
| Переменная | Описание |
|-----------|----------|
| `ORCH_STAGING` | Должна быть `true` — защита от случайного запуска на проде |
| `ORCH_PLANE_API_TOKEN` | Plane API token (`X-API-Key`) |
| `ORCH_PLANE_API_URL` | Plane base URL **без** `/api/v1` (скрипт добавляет сам) |
| `ORCH_PLANE_WORKSPACE_SLUG` | Workspace slug (`ag_proj`) |
| `ORCH_GITEA_TOKEN` | Gitea token (`Authorization: token …`) |
| `ORCH_GITEA_URL` | Gitea base URL (`http://localhost:3000`) |
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-секрет для подписи `/webhook/plane` (если пустой — без подписи) |
Все эти переменные **уже есть** внутри контейнера `orchestrator-staging`.
---
## Способы запуска
### 1. Внутри контейнера (КАНОНИЧЕСКИЙ — обязателен для деплоера)
```bash
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
Это единственный канонический способ для стадии `deploy-staging` (ORCH-048, ADR-001).
Внутри контейнера env уже staging (`.env.staging`), а чек **B6** строит реестр проектов из
собственного process-env инстанса (см. ниже). Путь к скрипту — `/repos/orchestrator/scripts/…`
(bind-mount); `scripts/` **не** копируется в образ, поэтому `/app/scripts` не существует.
### 2. С хоста — НЕ рекомендуется
```bash
# ⚠️ Воспроизводит баг ORCH-048: на хосте ORCH_PROJECTS_JSON не задан →
# B6 строит реестр из дефолта (ET+ORCH) → ложный FAIL.
# Допустимо ТОЛЬКО если env хоста полностью повторяет staging (включая ORCH_PROJECTS_JSON).
export ORCH_STAGING=true
export ORCH_PROJECTS_JSON=... # обязателен, иначе B6 даст ложный FAIL
export ORCH_PLANE_API_TOKEN=...
# ... остальные переменные ...
python3 scripts/staging_check.py --base-url http://localhost:8501 --mode stub
```
---
## Механика чека B6 (ORCH-048, ADR-001)
B6 «Registry: sandbox present, prod ET/ORCH absent» подтверждает изоляцию: в реестре
работающего staging-инстанса есть только sandbox-проект и НЕТ боевых (ET/ORCH).
- B6 импортирует `known_plane_project_ids()` из `src.projects` **кода контейнера**
(`/app/src` через `PYTHONPATH=/app`), env которого — `.env.staging`. Реестр отражает
именно работающий staging-инстанс.
- Прежний host-path хак (`sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`)
удалён: он подхватывал env процесса-запускателя и при запуске с хоста давал ложный FAIL.
- Логика вердикта вынесена в чистую функцию `_evaluate_b6(known) -> (passed, detail)`:
`passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`. Покрыта юнит-тестами
(`tests/test_staging_check_b6.py`) на оба исхода без поднятия инстанса/docker.
- При недоступности источника реестра B6 даёт детерминированный FAIL (не ложный PASS,
не необработанное исключение).
**Поэтому B6 достоверен только при каноническом запуске (способ 1).**
---
## Режимы (`--mode`)
| Режим | Описание | Скорость |
|-------|----------|----------|
| `stub` (дефолт) | Проверяет **ранние артефакты** конвейера: ветка + QG-0-коммент. Создаются ДО запуска Claude CLI → быстро, детерминированно, без расхода LLM-кредитов. | ~30-90 сек |
| `full-real` | Дополнительно ждёт реального завершения аналитика. Долго, расходует LLM-кредиты. | 5-15+ мин |
**Текущий дефолт: `stub`** — достаточен для проверки работоспособности стенда.
---
## Что проверяет блок C (E2E) и почему это безопасно
Порядок `start_pipeline` в коде orchestrator:
1. Resolve проекта из реестра
2. Получить name/description из Plane API (если в webhook пустые)
3. **QG-0 гейт** (name ≥ 5 симв, description ≥ 20 симв)
4. **Создать work_item_id + ветку в Gitea + начальные доки**
5. **Записать строку задачи в БД**
6. Поставить аналитика в очередь (вот тут Claude CLI)
Блок C проверяет **шаги 4-5**, аналитика (шаг 6) **не ждёт**.
Тест-задача создаётся ТОЛЬКО в **SANDBOX** (`project_id 8c5a3025-...`),
ветка создаётся ТОЛЬКО в **orchestrator-sandbox**.
### CLEANUP (обязателен)
`try/finally` гарантирует удаление тестовых артефактов:
- Удаляет ветку из `orchestrator-sandbox`
- Удаляет задачу из Plane SANDBOX
Cleanup отрабатывает даже при падении e2e.
---
## Принцип HMAC-подписи
Скрипт читает `ORCH_PLANE_WEBHOOK_SECRET` из env и формирует подпись:
```python
hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
```
Передаёт как заголовок `X-Plane-Signature`. Алгоритм совпадает с `verify_plane_signature` в `src/webhooks/plane.py`.
---
## Изолированность от прода
| Проверка | Гарантия |
|---------|---------|
| A3 `ORCH_STAGING=true` | При false — abort до деструктивных блоков |
| B6 Реестр без боевых | ET/ORCH project_id absent в `known_plane_project_ids()` |
| C: only SANDBOX project_id | Webhook payload указывает только `8c5a3025-...` |
| C: only orchestrator-sandbox repo | Gitea operations на `admin/orchestrator-sandbox` |
| C: cleanup в finally | Артефакты удаляются даже при ошибке |
---
## Добавление в деплой-хук
```bash
# В deploy.sh, после docker-compose up -d orchestrator-staging
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py --mode stub
if [ $? -ne 0 ]; then
echo "Staging check FAILED — rolling back"
exit 1
fi
```

View File

@@ -0,0 +1,7 @@
# Business Request: Единообразные коммент-артефакты в Plane от всех агентов
Work Item ID: ORCH-016
## Description
TBD

View File

@@ -0,0 +1,85 @@
# BRD: Единообразные коммент-артефакты в Plane от всех агентов
Work Item ID: **ORCH-016**
Стадия: analysis
Автор: analyst
Дата: 2026-06-05
Ревизия: 2 (учтён фидбэк стейкхолдера от 2026-06-05 — добавить длительность работы агента в коммент)
---
## 1. Бизнес-цель
Стейкхолдер (Слава) должен мочь из ленты комментариев задачи в Plane **за один клик** перейти к артефакту любого агента (ADR, PR, ревью, отчёт тестера, деплой-лог), а не разбирать «шумные» строки без удобной ссылки и человекочитаемого описания.
Помимо ссылок, по комментариям стейкхолдер хочет **видеть, сколько работал каждый агент** (длительность стадии), не открывая БД оркестратора и не лезя в `agent_runs`.
## 2. Мотивация
Сейчас в Plane комменты двух разных стилей:
| Кто пишет | Формат коммента | Источник |
|-----------|-----------------|----------|
| **Аналитик (эталон)** | HTML: человеческое описание стадии + `<ul>` со списком ссылок на артефакты, заголовок «Документы:» | `src/stage_engine.py::_build_analyst_ready_comment` (PR #13) |
| Architect / Developer / Reviewer / Tester / Deployer | Однострочник «{icon} Role готов · 8.5M in / 45.8k out · $7.29» + markdown-ссылки следом | `src/usage.py::usage_comment` + `artifact_links` |
Проблемы второго формата:
1. Нет человеческого описания результата стадии — есть только техническая метрика «tokens/cost».
2. Нет краткого вердикта одной строкой там, где он есть в артефакте (Reviewer `APPROVE/REQUEST_CHANGES`, Tester `PASS/FAIL`, Deployer `SUCCESS/FAILED`).
3. Формат разнится по агентам (где-то «📂 Branch + 🔗 PR», где-то «📄 Test report») — нет единого визуального якоря.
4. **Не видно длительности стадии** — стейкхолдер не понимает, агент отработал за 30 секунд или за 12 минут; это важная метрика для оценки SLA, поведения долгих стадий (testing/deploy) и подозрений на «зависание».
## 3. Целевая аудитория
- **Стейкхолдер задачи (Слава, владелец продукта)** — главный потребитель ленты комментариев в Plane.
- **Reviewer / QA / DevOps по другим проектам (enduro-trails)** — те же ссылки помогут им навигироваться по задачам, не открывая БД оркестратора.
## 4. Scope (что входит)
1. Привести коммент-формат **architect, developer, reviewer, tester, deployer** к единому виду по эталону аналитика:
- заголовок-роль (emoji + имя роли),
- короткое человеческое описание результата стадии (1 предложение),
- кликабельная ссылка(и) на СВОЙ артефакт,
- **одна строка-вердикт** там, где это уместно (Reviewer / Tester / Deployer),
- **одна строка-длительность** работы агента — для всех ролей, включая аналитика.
2. Переиспользовать `settings.gitea_public_url` для кликабельных ссылок (готово в PR #14).
3. Сохранить существующее поведение аналитика (PR #13) — он уже соответствует целевому формату; в идеале — переиспользовать общий хелпер. К аналитику также добавляется строка длительности.
4. Один коммент на агента за прохождение стадии (без спама).
5. Источник длительности — уже существующая метрика `_duration_s` в `src/agents/launcher.py` (или `agent_runs.started_at` / `finished_at`). Новых таблиц/полей в БД не заводим.
## 5. Out of scope (что НЕ трогаем)
- Логика Quality Gates (`src/qg/checks.py`).
- Status-only verdict model (PR #12) — приёмка аналитика через смену статуса Plane на «Approved/Rejected».
- Дедупликация вебхуков (`src/webhooks/_dedup.py`).
- `set_issue_done`, `notify_done`, `notify_qg_failure` — внутренние нотификации остаются как есть.
- Per-agent bot-авторство (PR с `PLANE_BOT_TOKENS`) — сохраняется.
- Изменение схемы БД, конвейера стадий, реестра QG.
## 6. Бизнес-требования
**BR-1.** Каждый агент по завершении своей стадии (вне пути ошибки) пишет в Plane **ровно один** коммент в едином формате.
**BR-2.** Коммент содержит:
- заголовок с emoji-иконкой роли и человекочитаемым названием,
- 12 предложения с описанием результата стадии на русском языке,
- кликабельную ссылку (-и) на артефакт(ы) этого агента в Gitea,
- одну строку вердикта (Verdict / Status), если артефакт его содержит,
- **одну строку длительности работы агента** (`Длительность: <human-format>`), всегда, если значение известно.
**BR-3.** Ссылки строятся через `gitea_public_url` (fallback на `gitea_url`).
**BR-4.** Формат должен быть устойчив: отсутствующий артефакт / отсутствующий вердикт / неизвестная длительность не ломают коммент — соответствующая строка просто опускается.
**BR-5.** Изменение **не нарушает**:
- status-only verdict model (аналитик по-прежнему ждёт смены статуса Plane),
- дедуп комментов и вебхуков,
- работу `set_issue_done` / `notify_done` на финале конвейера,
- per-agent bot-авторство.
**BR-6.** Длительность отображается в человекочитаемой форме (`12s`, `4m 12s`, `1h 03m`), а не в виде голых секунд. Источник — `agent_runs.started_at` / `finished_at` (или уже посчитанный `_duration_s` в `launcher.py`). Новых полей в БД не вводится.
## 7. Ограничения и риски
- **Self-hosting:** оркестратор правит сам себя; деплой только через staging-гейт (порт 8501) → прод-контейнер `orchestrator` не перезапускать в рамках задачи.
- Прод обслуживает другие проекты (enduro-trails) — нельзя сломать комменты в их задачах.
- Plane Bot-авторство (`_headers_for`) должно остаться — коммент пишется под бот-токеном своей роли.
- Reviewer/tester вердикты читаются из артефактов; нужно идемпотентно работать, если артефакт ещё не закоммичен / не доступен в worktree.
## 8. Связки
- PR #13`status-only analyst comment with doc links` (эталон формата аналитика).
- PR #14`external gitea_public_url for clickable doc links` (источник кликабельных ссылок).
- ADR не требуется: сквозной архитектурный сдвиг отсутствует, меняем только формирование текста коммента в существующем потоке.
## 9. Критерии успеха (high-level)
- Слава открывает любую задачу в Plane и в ленте видит однотипные карточки от каждого агента: «{role} — {описание} → ссылка [Verdict: …] [Длительность: …]».
- По любой ссылке открывается соответствующий документ в Gitea (HTTP 200, корректный путь).
- В каждом статус-комменте присутствует строка «Длительность: …» с человекочитаемым значением (`12s` / `4m 12s` / `1h 03m`).
- Никаких регрессий в существующих тестах `tests/`.

View File

@@ -0,0 +1,174 @@
# ТЗ: Единообразные коммент-артефакты в Plane от всех агентов
Work Item ID: **ORCH-016**
Стадия: analysis → architecture → development
Автор: analyst
Дата: 2026-06-05
Ревизия: 2 (по фидбэку стейкхолдера — добавлен §2.5 Duration; обновлены §1, §2.1, §6)
> Контракт: что именно меняем в коде / какие модули задействованы / какие проверки появятся.
> Архитектурные решения принимает архитектор; здесь — границы изменения.
---
## 1. Задействованные модули
| Модуль | Роль в изменении |
|--------|------------------|
| `src/usage.py` | **Главная точка изменения.** Здесь сейчас живут `usage_comment()`, `artifact_links()`, `AGENT_ARTIFACT`, `AGENT_DISPLAY`, `AGENT_ICON` — основа форматирования. Нужно расширить/добавить хелпер построения единого status-коммента + утилитку форматирования длительности (`fmt_duration(seconds: int) -> str`). |
| `src/stage_engine.py` | Эталонная функция аналитика `_build_analyst_ready_comment()`. По возможности — переиспользовать новый общий хелпер (или хотя бы выровнять формат: emoji + заголовок + описание + список ссылок). К аналитику также прикручиваем строку длительности (см. §2.5). |
| `src/agents/launcher.py` | `_post_usage_comments()` — точка, где постится коммент по завершении агента (architect/developer/reviewer/tester/deployer). Должен звать новый хелпер. `_duration_s` уже считается на строке `391` — пробросить его (или достать из `agent_runs.started_at`/`finished_at`) в хелпер. |
| `src/db.py` | **Только для чтения** в рантайме коммент-хелпера: `agent_runs.started_at`, `agent_runs.finished_at` (уже существуют). Никаких ALTER. |
| `src/plane_sync.py` | `add_comment()` — без изменений (используется как транспорт). |
| `src/qg/checks.py` | **Только для чтения**: модели парсинга frontmatter `verdict:` / `deploy_status:` / `staging_status:` — переиспользуем эту логику (вынести в отдельную утилитку, либо импортировать там, где она уже есть). |
| `src/config.py` | `settings.gitea_public_url`, `settings.gitea_owner`, `settings.gitea_url` — без изменений, переиспользуются. |
## 2. Контракт нового коммент-формата
### 2.1 Структура (одинакова для всех агентов)
```
{ICON} {RoleName} — {one-line human description of stage result}
[Verdict / Status: <VALUE>] # опционально, см. 2.3
Длительность: <human-format> # см. 2.5; опускается, только если значение неизвестно
<b>Документы:</b>
• <a href="…">{label}</a> # одна или несколько ссылок
```
Поля:
- `{ICON}` — берётся из `AGENT_ICON` (уже есть в `usage.py`).
- `{RoleName}` — из `AGENT_DISPLAY` (уже есть).
- `{description}` — фиксированная строка на роль, см. 2.2.
- Verdict / Status — см. 2.3, опускается если не извлекается.
- Длительность — см. 2.5, печатается всегда, когда значение есть; по умолчанию доступна (это нативная метрика `agent_runs`).
- Ссылки — см. 2.4.
### 2.2 Описания стадий (per-agent text)
| Агент | Описание (рус.) |
|-------|------------------|
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved.» (как сейчас в `_build_analyst_ready_comment`) |
| architect | «Завершил архитектурную проработку. См. ADR ниже.» |
| developer | «Завершил разработку. См. PR / branch ниже.» |
| reviewer | «Завершил ревью изменений.» |
| tester | «Завершил прогон тестов.» |
| deployer | «Завершил деплой.» |
Точные формулировки финализирует architect; аналитик фиксирует **факт** наличия 1-предложного описания на каждую роль.
### 2.3 Verdict / Status строка
Печатается отдельной строкой над списком документов. Источник — frontmatter артефакта; парсить идемпотентно (если файл недоступен — строку пропустить):
| Агент | Поле | Где парсим | Возможные значения | Формат строки |
|-------|------|------------|---------------------|----------------|
| analyst | — | — | — | не печатается |
| architect | — | — | — | не печатается |
| developer | — | — | — | не печатается (CI-статус — отдельный гейт) |
| reviewer | `verdict:` | `docs/work-items/<wid>/12-review.md` (YAML-frontmatter) | `APPROVE` / `REQUEST_CHANGES` | `Verdict: APPROVE` |
| tester | `verdict:` (или эквивалентный фронт-кей) | `docs/work-items/<wid>/13-test-report.md` | `PASS` / `FAIL` | `Verdict: PASS` |
| deployer | `staging_status:` (для deploy-staging) / `deploy_status:` (для deploy) | `15-staging-log.md` / `14-deploy-log.md` | `SUCCESS` / `FAILED` | `Status: SUCCESS` |
Если значение в frontmatter отсутствует или не распознано → строка `Verdict / Status` НЕ выводится (вердикт-парсинг гейтов и сама логика гейтов не меняется).
### 2.4 Ссылки на артефакты
Базовый URL: `(settings.gitea_public_url or settings.gitea_url).rstrip('/')`.
Префикс: `/{owner}/{repo}/src/branch/{branch}/`.
| Агент | Артефакты (label → путь) |
|-------|----------------------------|
| analyst | BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, Test Plan `04-test-plan.yaml` *(уже есть)* |
| architect | ADR-папка `docs/work-items/<wid>/06-adr/` *(уже есть)* |
| developer | Branch `…/src/branch/<branch>`, PR `…/pulls/<num>` *(уже есть)* |
| reviewer | Review `docs/work-items/<wid>/12-review.md` *(уже есть)* |
| tester | Test report `docs/work-items/<wid>/13-test-report.md` *(уже есть)* |
| deployer | Deploy log `docs/work-items/<wid>/14-deploy-log.md`; staging-лог `15-staging-log.md` (если применимо к стадии) |
Несуществующий файл в worktree → ссылка опускается (как сейчас в `_build_analyst_ready_comment`).
### 2.5 Строка длительности работы агента
**Что печатаем:** одну строку вида `Длительность: {human}` (или `Duration: {human}` — финальную локализацию метки фиксирует архитектор; русский предпочтителен, остальные комменты уже на русском).
**Источник значения (приоритет сверху вниз):**
1. **Параметр функции**`_post_usage_comments()` в `src/agents/launcher.py:682` вызывается из контекста, где `_duration_s` уже посчитан на строке `391` (`int(time.time() - _start_ts)`). Простейший путь — пробросить `duration_s` явным аргументом в `usage_comment(...)` / новый `build_status_comment(...)`.
2. **Fallback из БД** — если параметр не передан (например, для аналитика, чей коммент строится в `_build_analyst_ready_comment` в `src/stage_engine.py:298`), читаем
```sql
SELECT
CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
FROM agent_runs
WHERE task_id = ? AND agent = ?
ORDER BY id DESC LIMIT 1
```
Это последний завершённый run этой роли по задаче.
3. **Если оба источника пусты / `None` / отрицательны** — строка `Длительность:` НЕ печатается (graceful, как и для вердикта).
**Форматирование (`fmt_duration(seconds: int) -> str` в `src/usage.py`):**
| Диапазон | Формат | Пример |
|----------|--------|--------|
| `0 ≤ s < 60` | `{s}s` | `12s`, `45s` |
| `60 ≤ s < 3600` | `{m}m {ss}s` | `4m 12s`, `1m 03s` |
| `s ≥ 3600` | `{h}h {mm}m` (секунды отбрасываем) | `1h 03m`, `2h 47m` |
Округление: целые секунды (input — `int`). При `s == 0` всё равно печатаем `0s` (видно, что метрика известна и стадия отработала почти мгновенно).
**Покрытие ролей:** строка длительности добавляется для **всех** агентов, включая аналитика. Для аналитика — строго через fallback из `agent_runs` (его коммент строится в `stage_engine.py`, не в `launcher.py`).
**Что НЕ делаем:**
- Не меняем схему `agent_runs` (поля `started_at` / `finished_at` уже есть, `_duration_s` уже считается).
- Не изобретаем новый отдельный коммент с длительностью — длительность встраивается в существующий status-коммент.
- Не считаем «время от первого вебхука до коммента» — берём чистое время процесса агента (тот же `_duration_s`, что попадает в `notify_agent_finished`), чтобы значение совпадало с тем, что уже видно в Telegram live tracker / логах.
### 2.6 Один коммент на агента за стадию
Текущий триггер — `_post_usage_comments()` вызывается **один раз** в успешном auto-advance пути после агента. Никаких новых триггеров не добавляем. Дубликаты исключены текущей логикой (одно завершение агента → один коммент).
### 2.7 Usage-метрики (токены / стоимость)
Текущий `usage_comment()` встраивает «8.5M in / 45.8k out · $7.29» в первый строкой. По требованиям Славы это «без раздувания», но не запрещено явно. Решение:
- **Сохранить** usage-метрику как **последнюю строку** коммента (мелким техническим хвостом, например `<sub>8.5M in / 45.8k out · $7.29 · Длительность: 4m 12s</sub>`), либо
- **Перенести** в `task_summary_comment` (только для финального deployer-summary).
Финальный выбор — за архитектором (см. вопрос Q-1 в `10-tech-risks.md`). Длительность из §2.5 — **отдельная** строка от usage-метрики и присутствует независимо от того, как решится вопрос про токены/стоимость.
### 2.8 Бот-авторство
`plane_add_comment(..., author=<role>)` — сохраняется. Все агенты комментируют под своим bot-токеном (`PLANE_BOT_TOKENS`). Изменения формата текста на это не влияют.
## 3. Изменения API
**Нет.** Внешние webhooks (`/webhook/plane`, `/webhook/gitea`), `/health`, `/status`, `/queue` — не меняются.
## 4. Изменения схемы БД
**Нет.** Используются существующие таблицы `tasks`, `agent_runs`, `jobs`.
## 5. Новые Quality Gate checks
**Нет.** Гейты не меняются. Парсинг `verdict:` / `deploy_status:` / `staging_status:` в коммент — отдельная утилитка, не QG.
## 6. Требования к коду
- Все новые функции — с docstring (зачем нужны, какие инварианты сохраняют).
- Парсинг frontmatter артефакта — graceful: исключение → строка вердикта опускается, лог в `logger.debug`.
- Чтение длительности — graceful: исключение или `None` → строка длительности опускается, лог в `logger.debug`. Отрицательные / нулевые значения: `0` печатается как `0s`, отрицательные опускаются.
- `fmt_duration(seconds: int) -> str` — чистая, без БД-зависимостей, легко тестируется юнитом.
- Никаких новых внешних зависимостей: использовать `pyyaml` (уже в проекте) или существующий парсер frontmatter из `src/qg/checks.py`.
- Поведение для проектов **без** артефактов (например, ENDURO-* до запуска агента) — graceful no-op: коммент с описанием и без ссылок (минимум — заголовок).
- HTML (как у аналитика) предпочтительнее markdown — Plane корректно рендерит `<ul><li><a>` и `<b>`.
## 7. Артефакты по pipeline
- `06-adr/` — **не требуется** (нет архитектурного сдвига; обсуждается локально архитектором, в случае спорного решения по 2.6 — заводим ADR `ADR-001-status-comment-format.md`).
- `07-infra-requirements.md` — **не требуется** (нет новой инфраструктуры).
- `08-data-requirements.md` — **не требуется** (БД не меняется).
- `12-review.md` / `13-test-report.md` / `14-deploy-log.md` — формируются на соответствующих стадиях по канону.
- `CHANGELOG.md` — обновить в том же PR (раздел `Unreleased`).
## 8. Документация
В том же PR обновить:
- `docs/architecture/README.md` — короткое упоминание единого формата комментов (можно в раздел «Plane Sync»).
- `docs/architecture/internals.md` — если там есть раздел про `usage.py`/комменты — обновить.
- `CLAUDE.md` — без изменений (правила не меняются).
## 9. Чего НЕ делать
- Не менять реестр `QG_CHECKS`.
- Не менять `STAGE_TRANSITIONS`.
- Не менять `add_comment` / `_headers_for` / `PLANE_BOT_TOKENS`.
- Не «комментировать» комменты других стадий задним числом.
- Не использовать `--no-verify` при коммитах.

View File

@@ -0,0 +1,125 @@
# Acceptance Criteria: Единообразные коммент-артефакты в Plane
Work Item ID: **ORCH-016**
Ревизия: 2 (по фидбэку стейкхолдера — все AC по агентам обновлены под строку длительности; добавлены AC-13 / AC-14)
Каждый AC сформулирован как чёткое условие PASS/FAIL. Проверяется автоматически (unit/integration) либо ручной верификацией в staging Plane (порт 8501).
---
## AC-1. Архитектор пишет единообразный коммент
- **Given** task завершила стадию `architecture` успешно, `06-adr/` содержит как минимум один ADR.
- **When** `_post_usage_comments(agent="architect", ...)` вызывается.
- **Then** в Plane появляется **ровно один** коммент со структурой:
- первая строка: `📐 Architect — Завершил архитектурную проработку. См. ADR ниже.`,
- строка `Длительность: <human>` (формат — см. AC-13), значение соответствует фактическому времени работы архитектора (±1с),
- блок «Документы:» с кликабельной ссылкой на `…/src/branch/<branch>/docs/work-items/<wid>/06-adr/`,
- **нет** строки `Verdict / Status`.
- **And** автор коммента — `architect` (`PLANE_BOT_TOKENS["architect"]`, fallback на shared token).
- **PASS** при выполнении всех пунктов; **FAIL** при отсутствии любого.
## AC-2. Разработчик пишет единообразный коммент
- **Given** task завершила стадию `development`, есть open PR.
- **When** `_post_usage_comments(agent="developer", ...)` вызывается.
- **Then** коммент в Plane:
- `💻 Developer — Завершил разработку. См. PR / branch ниже.`,
- строка `Длительность: <human>`,
- ссылки: `Branch <branch>``…/src/branch/<branch>`, `PR #<num>``…/pulls/<num>`,
- **нет** строки `Verdict`.
## AC-3. Ревьюер пишет коммент с вердиктом
- **Given** `12-review.md` содержит frontmatter `verdict: APPROVE` (или `REQUEST_CHANGES`).
- **When** `_post_usage_comments(agent="reviewer", ...)` вызывается.
- **Then** коммент:
- `🔎 Reviewer — Завершил ревью изменений.`,
- строка `Verdict: APPROVE` (или `REQUEST_CHANGES`) — содержимое соответствует frontmatter,
- строка `Длительность: <human>`,
- ссылка `Review``…/12-review.md`.
- **And** если frontmatter не содержит `verdict:` или файл недоступен — строка `Verdict:` опускается, остальное (в т.ч. длительность) публикуется.
## AC-4. Тестер пишет коммент с вердиктом
- **Given** `13-test-report.md` содержит frontmatter `verdict: PASS` (или `FAIL`).
- **When** `_post_usage_comments(agent="tester", ...)` вызывается.
- **Then** коммент:
- `🧪 Tester — Завершил прогон тестов.`,
- строка `Verdict: PASS` (либо `FAIL`),
- строка `Длительность: <human>`,
- ссылка `Test report``…/13-test-report.md`.
## AC-5. Деплоер пишет коммент со статусом
- **Given** task прошла стадию `deploy` (или `deploy-staging`), артефакт-лог существует с frontmatter `deploy_status: SUCCESS` (или `staging_status: SUCCESS`).
- **When** `_post_usage_comments(agent="deployer", ...)` вызывается.
- **Then** коммент:
- `🚀 Deployer — Завершил деплой.`,
- строка `Status: SUCCESS` (или `FAILED`),
- строка `Длительность: <human>`,
- ссылка `Deploy log``…/14-deploy-log.md` (и/или `Staging log``…/15-staging-log.md` для staging-стадии).
## AC-6. Аналитик не регрессирует
- **Given** существующий поток PR #12/#13 (status-only verdict).
- **When** аналитик завершает стадию `analysis` с готовыми `01..04`.
- **Then** в Plane:
- issue переведён в `In Review` (не меняется),
- коммент содержит **то же** человеческое описание (Approved/Rejected инструкции) и список ссылок `BRD / ТЗ / AC / Test Plan` — формат либо идентичен текущему, либо построен через тот же общий хелпер, что и остальные агенты, без потери смысла,
- дополнительно к существующему содержимому в комменте присутствует строка `Длительность: <human>` — значение поднимается из `agent_runs` (последний завершённый run агента `analyst` для этой задачи).
## AC-7. Один коммент на агента за стадию
- **Given** агент успешно отработал стадию.
- **When** наблюдаем ленту Plane.
- **Then** для **каждого** агента (`architect`, `developer`, `reviewer`, `tester`, `deployer`) на стадию приходится **ровно один** status-коммент с артефактами. Дополнительные сервисные комменты (`notify_stage_change`, `notify_qg_failure`, `notify_done`) сохраняются — они не считаются status-комментом.
## AC-8. Graceful fallback при отсутствии артефакта
- **Given** артефакт (например, `12-review.md`) ОТСУТСТВУЕТ в worktree на момент коммента (нестандартный сценарий).
- **When** `_post_usage_comments(agent="reviewer", ...)` вызывается.
- **Then** коммент всё равно публикуется: заголовок + описание, без ссылки на отсутствующий артефакт и без строки `Verdict:`. Исключения не пробрасываются.
## AC-9. Кликабельность через gitea_public_url
- **Given** в `.env` задан `GITEA_PUBLIC_URL=https://git.mva154.duckdns.org`, отличный от `GITEA_URL`.
- **When** любой агент пишет status-коммент.
- **Then** href всех артефакт-ссылок начинается с `https://git.mva154.duckdns.org/` (а не с внутреннего `gitea_url`).
- **And** при отсутствии `gitea_public_url` (пустая строка) — fallback на `gitea_url` (обратная совместимость).
## AC-10. Существующие тесты зелёные
- **Given** новый код влит в feature-ветку.
- **When** запускается `pytest tests/ -q`.
- **Then** все ранее существовавшие тесты проходят (нет регрессий status-only verdict, дедупа, `set_issue_done`).
## AC-11. Quality Gates не меняются
- **Given** изменения формата комментов.
- **When** инспектируется `src/qg/checks.py` и `src/stages.py`.
- **Then** реестр `QG_CHECKS` и `STAGE_TRANSITIONS` остаются идентичными версии до PR (diff в этих файлах = ∅).
## AC-12. Документация обновлена
- **Given** реализация добавлена в feature-ветку.
- **When** reviewer проверяет PR.
- **Then** в diff присутствуют обновления:
- `CHANGELOG.md` (раздел Unreleased, описание изменения — включая «строку длительности агента в комментах»),
- `docs/architecture/README.md` или `docs/architecture/internals.md` (упоминание единого формата status-комментов и строки длительности).
- **And** при отсутствии обновлений документации reviewer ставит `verdict: REQUEST_CHANGES` (правило проекта).
## AC-13. Формат строки длительности
- **Given** утилитка `fmt_duration(seconds: int) -> str` в `src/usage.py`.
- **When** ей передаются граничные значения.
- **Then** возвращаемая строка соответствует таблице:
- `0``"0s"`
- `12``"12s"`
- `59``"59s"`
- `60``"1m 00s"`
- `252``"4m 12s"`
- `3599``"59m 59s"`
- `3600``"1h 00m"`
- `3780``"1h 03m"`
- `10020``"2h 47m"`
- **And** ввод `None` или отрицательное значение → функция возвращает пустую строку (или `None`), а вызывающая сторона строку `Длительность:` не печатает.
- **PASS** при полном совпадении со всеми примерами таблицы.
## AC-14. Длительность — graceful fallback
- **Given** агент завершился, но `_duration_s` не пробрасывается явным параметром в коммент-хелпер (например, для аналитика).
- **When** строится status-коммент.
- **Then** хелпер запрашивает БД: последний `agent_runs` для `(task_id, agent)` с непустым `finished_at`, считает `int((julianday(finished_at) - julianday(started_at)) * 86400)` и подставляет в `fmt_duration`.
- **And** при отсутствии подходящей строки `agent_runs` (или `finished_at IS NULL`, или результат < 0) — строка `Длительность:` опускается; остальные части коммента (заголовок, описание, вердикт, ссылки) публикуются без изменений.
- **And** ошибка чтения БД не пробрасывает исключение наружу — логируется в `logger.debug` и трактуется как «значение неизвестно».
---
**Финальный PASS задачи:** все AC-1…AC-14 = PASS.

View File

@@ -0,0 +1,154 @@
work_item: ORCH-016
title: "Единообразные коммент-артефакты в Plane от всех агентов"
revision: 2 # +TC-21..TC-25 по длительности (фидбэк стейкхолдера)
tests:
- id: TC-01
type: unit
description: "build_status_comment(architect, duration_s=312, ...) формирует HTML c заголовком '📐 Architect — …', описанием стадии, строкой 'Длительность: 5m 12s' и ссылкой на 06-adr/. Строки Verdict нет."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-02
type: unit
description: "build_status_comment(developer, branch=..., pr_number=42, duration_s=...) включает ссылки на branch и на PR #42 через gitea_public_url + строку 'Длительность: ...'. Строки Verdict нет."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-03
type: unit
description: "build_status_comment(reviewer, duration_s=..., ...) при verdict=APPROVE в 12-review.md frontmatter выводит строку 'Verdict: APPROVE', строку 'Длительность: ...' и ссылку на 12-review.md."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-04
type: unit
description: "build_status_comment(reviewer, ...) при verdict=REQUEST_CHANGES выводит 'Verdict: REQUEST_CHANGES'. Строка длительности сохраняется."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-05
type: unit
description: "build_status_comment(reviewer, ...) при отсутствии файла 12-review.md публикует коммент без строки Verdict и без ссылки Review (graceful), при этом строка 'Длительность: ...' печатается, если duration_s передан."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-06
type: unit
description: "build_status_comment(tester, ...) при verdict=PASS в 13-test-report.md выводит 'Verdict: PASS', строку 'Длительность: ...' и ссылку на 13-test-report.md."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-07
type: unit
description: "build_status_comment(tester, ...) при verdict=FAIL выводит 'Verdict: FAIL'. Строка длительности сохраняется."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-08
type: unit
description: "build_status_comment(deployer, ...) при deploy_status=SUCCESS в 14-deploy-log.md выводит 'Status: SUCCESS', строку 'Длительность: ...' и ссылку на 14-deploy-log.md."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-09
type: unit
description: "build_status_comment(deployer, stage='deploy-staging') читает staging_status: из 15-staging-log.md и выводит соответствующую строку Status + строку длительности."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-10
type: unit
description: "URL ссылок строится через settings.gitea_public_url когда он задан; иначе — через settings.gitea_url (fallback)."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-11
type: unit
description: "Аналитик: _build_analyst_ready_comment (или его замена общим хелпером) сохраняет существующий контракт — текст про Approved/Rejected статус + список существующих BRD/ТЗ/AC/Test Plan ссылок. Дополнительно: при наличии завершённой строки agent_runs(analyst) для задачи коммент содержит строку 'Длительность: ...'."
module: tests/test_analyst_comment_regression.py
expected: PASS
- id: TC-12
type: unit
description: "Парсер frontmatter (verdict / deploy_status / staging_status) возвращает None при отсутствии файла, пустом файле или некорректном YAML — без проброса исключения."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-13
type: integration
description: "_post_usage_comments(agent='reviewer', ...) вызывает plane_sync.add_comment ровно один раз; передаваемый текст содержит '🔎 Reviewer', 'Verdict:', 'Длительность:' и href на 12-review.md."
module: tests/test_post_usage_comments_integration.py
expected: PASS
- id: TC-14
type: integration
description: "_post_usage_comments(agent='tester', ...) вызывает add_comment ровно один раз с автором 'tester' и корректным текстом, включая строку 'Длительность: ...'."
module: tests/test_post_usage_comments_integration.py
expected: PASS
- id: TC-15
type: integration
description: "_post_usage_comments(agent='deployer', ...) для стадии deploy постит коммент со ссылкой на 14-deploy-log.md, строкой 'Длительность: ...' И task_summary_comment (если оно сохраняется) — поведение не регрессирует."
module: tests/test_post_usage_comments_integration.py
expected: PASS
- id: TC-16
type: integration
description: "Регрессия status-only verdict model: при завершении analyst issue переводится в In Review, постится один коммент аналитика с инструкцией про статус Approved/Rejected, никакой автомат-advance не происходит."
module: tests/test_analyst_status_only_regression.py
expected: PASS
- id: TC-17
type: integration
description: "Регрессия дедупликации: повторный вебхук Plane с тем же event_id не приводит ко второму status-комменту от агента."
module: tests/test_status_comment_dedup_regression.py
expected: PASS
- id: TC-18
type: integration
description: "Регрессия set_issue_done / notify_done: финальный путь deploy→done по-прежнему переводит issue в Done и постит '✅ Task completed!' (отдельным комментом от status-коммента деплоера)."
module: tests/test_notify_done_regression.py
expected: PASS
- id: TC-19
type: integration
description: "Per-agent bot-авторство: status-комменты архитектора/разработчика/ревьюера/тестера/деплоера POST-ятся под соответствующим X-API-Key (PLANE_BOT_TOKENS[role]); fallback на PLANE_HEADERS при отсутствии бот-токена."
module: tests/test_status_comment_authorship.py
expected: PASS
- id: TC-20
type: unit
description: "Quality Gates не изменены: реестр QG_CHECKS и STAGE_TRANSITIONS идентичны контрольному снапшоту (smoke-тест против случайных правок)."
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-21
type: unit
description: "fmt_duration(seconds) — табличная проверка форматирования: 0→'0s', 12→'12s', 59→'59s', 60→'1m 00s', 252→'4m 12s', 3599→'59m 59s', 3600→'1h 00m', 3780→'1h 03m', 10020→'2h 47m'."
module: tests/test_fmt_duration.py
expected: PASS
- id: TC-22
type: unit
description: "fmt_duration(None) и fmt_duration(-1) возвращают пустую строку (или None); вызывающая сторона при этом строку 'Длительность:' НЕ печатает."
module: tests/test_fmt_duration.py
expected: PASS
- id: TC-23
type: unit
description: "build_status_comment(architect, duration_s=None) и build_status_comment(architect) — коммент НЕ содержит строки 'Длительность:'; остальные строки (заголовок/описание/ссылки) на месте."
module: tests/test_status_comment_format.py
expected: PASS
- id: TC-24
type: integration
description: "Fallback по БД: при отсутствии явного duration_s билдер коммента читает agent_runs.started_at/finished_at для последней завершённой строки (task_id, agent) и подставляет fmt_duration результата. Проверка через тестовую SQLite с заранее проставленными timestamp'ами."
module: tests/test_status_comment_duration_db_fallback.py
expected: PASS
- id: TC-25
type: integration
description: "Регрессия: исключение при чтении agent_runs (например, БД залочена) → строка 'Длительность:' опускается, остальное публикуется; logger.debug содержит запись о неудачном чтении длительности."
module: tests/test_status_comment_duration_db_fallback.py
expected: PASS

View File

@@ -0,0 +1,203 @@
# ADR-001: Единый формат status-коммента агентов в Plane
- **Work Item:** ORCH-016
- **Стадия:** architecture
- **Статус:** Accepted
- **Дата:** 2026-06-05
- **Автор:** architect
## Контекст
ТЗ ORCH-016 требует привести коммент-формат всех агентов (architect/developer/reviewer/tester/deployer + сохранение совместимости с analyst) к единому виду по эталону `src/stage_engine.py::_build_analyst_ready_comment` и дополнительно встроить **строку длительности работы агента**.
ТЗ оставил архитектору пять открытых вопросов (см. §2.2, §2.5, §2.7, §6):
1. Где живёт общий хелпер построения коммента (один файл vs. два).
2. Как ведём себя с usage-метрикой (tokens / $cost) в новом формате (Q-1 из ТЗ §2.7).
3. Локализация метки длительности — «Длительность:» vs «Duration:».
4. Парсинг frontmatter артефакта (verdict / deploy_status / staging_status) — переиспользовать `src/qg/checks.py` или дублировать.
5. Контракт хелпера БД-фоллбэка длительности и его форма.
Дополнительно: текущий `usage_comment(...)` — публичная (внутри проекта) функция, вызывается из `src/agents/launcher.py::_post_usage_comments`. Менять формат «на месте» без явного решения о судьбе старой сигнатуры рискованно.
## Решение
### 1. Архитектура хелперов
Вводим **ровно один публичный хелпер** в `src/usage.py`:
```python
def build_status_comment(
agent: str, # "analyst" | "architect" | ... | "deployer"
*,
repo: str | None = None,
branch: str | None = None,
work_item_id: str | None = None,
pr_number: int | None = None,
stage: str | None = None, # "deploy" vs "deploy-staging" (для deployer)
usage: dict | None = None, # tokens/cost (опционально)
duration_s: int | None = None, # если известно — иначе fallback по БД
task_id: int | None = None, # требуется ТОЛЬКО для DB-фоллбэка длительности
worktree_root: str | None = None, # для чтения артефактов; None → опускаем verdict
) -> str:
```
Что делает:
- Собирает заголовок `{ICON} {RoleName} — {описание}` (описание per-agent — см. §2 ниже).
- Опционально дописывает строку `Verdict: …` / `Status: …` (только для reviewer/tester/deployer и только если frontmatter артефакта присутствует и распознан).
- Всегда (если известна) дописывает строку `Длительность: …` через `fmt_duration(...)`.
- Дописывает блок `<b>Документы:</b><ul><li><a …>…</a></li>…</ul>`.
- Опционально дописывает технический хвост `<sub>{tokens}/{cost}</sub>` — см. §3.
`_build_analyst_ready_comment(...)` в `src/stage_engine.py` переписывается как **тонкая обёртка** над `build_status_comment(agent="analyst", ...)`. Аналитик-специфичный текст (инструкция «переведите в Approved/Rejected» + полный список 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan) добавляется ВНУТРИ `build_status_comment` через ветку `agent == "analyst"` — это единственное место, где per-agent текст шире одной строки. Альтернатива (передавать кастомный текст параметром) добавляет API-площадь без пользы.
**Старый `usage_comment(...)` удаляется**; единственный его внешний вызов — `src/agents/launcher.py::_post_usage_comments` — переписывается на `build_status_comment(...)`. Это упрощает дальнейшее сопровождение (один формат → одна функция); риск минимален, потому что `usage_comment` — внутренний API.
### 2. Per-agent описания (финализация ТЗ §2.2)
| Агент | Описание (HTML, без точки в конце) |
|-------|------------------------------------|
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved» (плюс существующая инструкция про Approved/Rejected уходит как продолжение) |
| architect | «Завершил архитектурную проработку. См. ADR ниже» |
| developer | «Завершил разработку. См. PR / branch ниже» |
| reviewer | «Завершил ревью изменений» |
| tester | «Завершил прогон тестов» |
| deployer (deploy) | «Завершил прод-деплой» |
| deployer (deploy-staging) | «Завершил staging-деплой» |
### 3. Решение по Q-1 (usage-метрика)
**Сохраняем** usage-метрику как **техническую `<sub>`-строку в конце** коммента, объединённую с длительностью НЕ нужно — длительность остаётся ОТДЕЛЬНОЙ строкой нормального веса (требование ТЗ §2.5).
Конкретно:
```html
<sub>8.5M in (8.4M cached) / 45.8k out · $7.29</sub>
```
Почему НЕ удаляем:
- Тех-метрика полезна для оценки стоимости задачи на пост-мортеме (особенно для ORCH-задач, где orchestrator расходует свой же бюджет).
- `task_summary_comment` (Deployer end-of-task) суммирует по задаче, но не покрывает per-agent breakdown в момент завершения каждой стадии — для трассировки «кто сколько потратил» полезно видеть сразу.
Почему `<sub>`, а не обычная строка:
- Стейкхолдер (Слава) явно просил «без раздувания»; визуально приглушённый хвост не конкурирует за внимание с описанием/вердиктом/длительностью/ссылками.
- Plane корректно рендерит `<sub>` (проверено ранее на PR #13).
При `usage = None` или нулевых значениях — хвост опускается полностью.
### 4. Решение по Q-2 (локализация метки длительности)
Используем русский: **`Длительность: 4m 12s`**.
Обоснование: все человеческие тексты комментов уже на русском (заголовок «Документы:», описания стадий). Метка `4m 12s` сама по себе универсальна и понятна без перевода (стандарт CLI-инструментов: `time`, `gh`, `kubectl`).
### 5. Решение по Q-4 (парсинг frontmatter)
Создаём НОВЫЙ маленький утилитный модуль **`src/frontmatter.py`** с единственной функцией:
```python
def read_frontmatter_value(path: str, key: str) -> str | None:
"""Read a single key from leading YAML frontmatter. Never raises.
Returns None if file missing, frontmatter absent/malformed, or key not set.
"""
```
Реализация — yaml.safe_load на блоке между двумя `---` строками; всё ловится одним `try/except``logger.debug``None`.
Этот модуль используют:
- `src/usage.py::build_status_comment` — для извлечения `verdict:` / `deploy_status:` / `staging_status:`.
- `src/qg/checks.py`НЕ обязательно мигрировать в этом PR (out-of-scope ORCH-016); миграция может пройти отдельной задачей-рефакторингом. **В этом PR `qg/checks.py` НЕ трогаем** — снижает blast radius и риск регрессии гейтов.
Дублирование (~10 строк YAML-парсера в `qg/checks.py` остаётся) сознательно принято: scope discipline > DRY на одном переиспользовании.
### 6. Решение по Q-5 (DB-фоллбэк длительности)
Хелпер в `src/usage.py`:
```python
def get_agent_duration(task_id: int, agent: str) -> int | None:
"""Return last finished agent_runs duration (seconds) for (task, agent).
Never raises. None on missing row / NULL finished_at / negative / error.
"""
```
SQL — ровно как в ТЗ §2.5 (фоллбэк):
```sql
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
FROM agent_runs
WHERE task_id=? AND agent=?
AND finished_at IS NOT NULL
ORDER BY id DESC LIMIT 1
```
Чтение через `get_db()` (стандартный путь модуля), обёрнутое в `try/except Exception``logger.debug(...)``None`. Соединение всегда закрывается в `finally`.
`build_status_comment` вызывает `get_agent_duration(...)` ТОЛЬКО когда:
- `duration_s is None`, И
- `task_id is not None` (вызывающая сторона согласилась оплатить лишний SELECT).
Если оба источника пусты → строка «Длительность:» опускается (AC-14).
### 7. Решение по HTML vs Markdown (ТЗ §6)
Целевой рендер — **HTML**, как у эталона аналитика. Конкретно:
- Заголовок и описание — plain text + emoji.
- Verdict / Длительность — отдельные строки, разделяются `<br>` (или `\n` если Plane корректно интерпретирует переводы строк; экспериментально подтвердить на staging — см. R-2 в `10-tech-risks.md`).
- Блок документов — `<b>Документы:</b><ul><li><a href="…">label</a></li></ul>`.
- Технический хвост — `<sub>…</sub>` отдельной строкой через `<br>`.
`artifact_links(...)` (сейчас возвращает markdown-строки `[label](url)`) — **переписывается на HTML-якоря** `<a href="...">label</a>`. Эмодзи-префиксы (📂/🔗/📐/📄) сохраняются. Возвращаемый тип меняется: `list[str]` остаётся, но содержимое — HTML-фрагменты (документировано в docstring).
Это breaking-change для внутреннего API `artifact_links`, но единственный внешний вызов был из `usage_comment`, который тоже удаляется. Других вызовов в `tests/`/`scripts/` нет (developer проверит grep'ом в development-стадии).
### 8. Контракт `fmt_duration` (полностью по AC-13)
```python
def fmt_duration(seconds: int | None) -> str:
"""0..59 → '{s}s'; 60..3599 → '{m}m {ss:02d}s'; >=3600 → '{h}h {mm:02d}m'.
None / negative → '' (caller should drop the line)."""
```
Чистая функция, без I/O, easily unit-testable. Размещение: `src/usage.py` (рядом с `fmt_tokens` / `fmt_cost`).
## Альтернативы
1. **Два отдельных хелпера** (`build_analyst_status_comment` + `build_agent_status_comment`).
Отклонено: ТЗ явно просит «единый эталонный формат»; дублирование шаблона расходится со временем.
2. **Оставить `usage_comment` как deprecated-обёртку.**
Отклонено: один внутренний вызов, deprecation добавляет когнитивный шум без выигрыша.
3. **Перенести usage-метрику в `task_summary_comment` (вариант B из ТЗ §2.7).**
Отклонено: теряем per-stage видимость затрат; финальный summary не отвечает на вопрос «сколько съел конкретно reviewer».
4. **Markdown вместо HTML.**
Отклонено: эталон аналитика (PR #13) уже HTML; смена ломает визуальный паритет.
5. **Английская метка «Duration:».**
Отклонено: ассиметрия с остальными русскими подписями в комменте.
6. **Рефакторить `qg/checks.py` на `src/frontmatter.py` в этом же PR.**
Отклонено: расширяет blast radius на гейты; делаем отдельной задачей.
## Последствия
### Положительные
- Единая точка изменения формата комментов на будущее — `build_status_comment`.
- Удаление дубликата `usage_comment` уменьшает API-площадь модуля.
- `src/frontmatter.py` подготавливает почву для будущего рефактора `qg/checks.py` (DRY-победа в один заход следующей задачей).
- HTML-рендеринг даёт стейкхолдеру кликабельные ссылки и приглушённый тех-хвост.
### Отрицательные / ограничения
- Дублирование YAML-парсинга на ~10 строк (qg/checks.py остаётся со своим).
- Дополнительный SELECT к `agent_runs` на каждый коммент аналитика (1 запрос, по индексу `task_id`, ничтожно).
- HTML-разметка ломается визуально, если Plane изменит политику санитизации `<sub>` или `<ul>` (риск R-2).
### Self-hosting
- Хелперы — чистый код, без рестарта прод-контейнера. Изменения дойдут до прода через стандартный staging-гейт (`deploy-staging``deploy`).
- Если коммент сломается, ленту Plane задачи ORCH-016 первой и заметим — feedback loop коротко.
## Связи
- ТЗ §1, §2, §6 (`docs/work-items/ORCH-016/02-trz.md`)
- AC-1..AC-14 (`docs/work-items/ORCH-016/03-acceptance-criteria.md`)
- PR #13 (эталон аналитика — `_build_analyst_ready_comment`)
- PR #14 (`gitea_public_url` для кликабельных ссылок)
- `src/usage.py`, `src/stage_engine.py`, `src/agents/launcher.py`, `src/db.py`, `src/qg/checks.py`

View File

@@ -0,0 +1,112 @@
# Технические риски — ORCH-016
Work Item: **ORCH-016**
Стадия: architecture
Автор: architect
Дата: 2026-06-05
> Риски ранжированы по приоритету (P0 = блокер, P1 = серьёзный, P2 = умеренный, P3 = информационный).
> Каждый риск содержит митигацию и/или способ детекции на тестах.
---
## R-1 (P1) — Self-hosting: сломанный коммент => слепая зона по ORCH-задаче
**Описание.** Изменение касается генерации комментов; орк дорабатывает сам себя. Если новый `build_status_comment` падает / отдаёт пустую строку / отдаёт битый HTML, стейкхолдер (Слава) потеряет видимость прогресса именно по той задаче, которая сломала комменты — и не сможет диагностировать без `docker logs`.
**Митигация.**
- Внешний `try/except Exception` вокруг сборки HTML: при любом исключении возвращаем простой fallback-текст вида `f"{icon} {role} готов"` + `logger.exception(...)`. Лучше «уродливый» коммент, чем тишина.
- Юнит-тесты `tests/test_status_comment_format.py` (TC-01..TC-12, TC-23) фиксируют золотой HTML — регрессия ловится на CI до прод-деплоя.
- Обязательный staging-гейт (`check_staging_status` для orchestrator) — финальный предохранитель: задача с ORCH-меткой не дойдёт до прод-контейнера, пока staging-инстанс (8501) не подтвердит, что комменты собираются.
## R-2 (P1) — Plane HTML sanitization: `<sub>` / `<br>` / `<ul>` могут не рендериться
**Описание.** Plane (self-hosted) санитизирует входящий HTML. Эталон аналитика подтверждает рендер `<ul>` / `<li>` / `<a>` / `<b>`; **рендер `<sub>` и `<br>` НЕ подтверждён** на текущей версии Plane.
**Митигация.**
- На staging (8501) опубликовать тестовый коммент `build_status_comment(...)` руками (через `python -m` скрипт или curl на dev-задачу) и визуально проверить рендер тех-хвоста и переводов строк ПЕРЕД мержем PR.
- Если `<sub>` не рендерится — fallback: оставить usage-метрику обычной строкой с `· ` разделителем (без `<sub>`).
- Если `<br>` не рендерится — переходим на `\n` (Plane сам интерпретирует) либо упаковываем строки в `<p>...</p>`.
- Развилка фиксируется в `12-review.md` reviewer'ом по факту проверки.
**Детекция.** Ручной чек-лист в staging-логе (`15-staging-log.md`) с приложенным скриншотом коммента.
## R-3 (P2) — SQLite contention при DB-фоллбэке длительности
**Описание.** `get_agent_duration(task_id, agent)` делает SELECT по `agent_runs` в момент сборки коммента. SQLite-БД одновременно используется очередью (`jobs`), воркером, вебхуками и Telegram-трекером; пиковая нагрузка → коротко блокирующиеся читатели.
**Митигация.**
- Запрос идёт по индексу `(task_id, agent)` (если его нет — добавление индекса не входит в scope ORCH-016, но запрос всё равно быстрый: типичный `agent_runs` ≤ 50 строк на задачу).
- `try/except Exception` оборачивает SELECT → `logger.debug(...)``None`. При залоченной БД строка «Длительность:» просто опускается (AC-14).
- Запрос делаем ТОЛЬКО когда `duration_s` не передан явно (т.е. только для аналитика).
**Детекция.** TC-25 — integration-тест на исключение в чтении `agent_runs`.
## R-4 (P3) — Расхождение значений длительности (param vs DB)
**Описание.** `_duration_s` в `src/agents/launcher.py:391` считается как `int(time.time() - _start_ts)`. DB-фоллбэк считает `(julianday(finished_at) - julianday(started_at)) * 86400`. Возможно расхождение в 1 секунду (округление) или больше (если `finished_at` пишется не сразу).
**Митигация.** AC-13 допускает погрешность ±1с. Для аналитика, где используем только DB-фоллбэк, отклонений между двумя источниками не наблюдается (источник один).
**Не митигируется специально** — последствия нулевые (декоративная строка).
## R-5 (P2) — Скрытые callers `usage_comment` / `artifact_links`
**Описание.** ADR-001 предписывает удалить `usage_comment` и переписать `artifact_links` на HTML. В рамках только grep по `src/` я нашёл единственного клиента — `_post_usage_comments` в `src/agents/launcher.py`. Однако функция могла использоваться скриптами (`scripts/`), тестами (`tests/`), миграционными утилитами или внешними интеграциями.
**Митигация.** Developer на стадии development обязан выполнить полный grep:
```bash
grep -rn "usage_comment\|artifact_links" . --include="*.py"
```
И переписать все вызовы. Если найдётся внешний потребитель — оставить `usage_comment` как deprecated-обёртку и зафиксировать в `12-review.md`.
**Детекция.** TC-10 (полный pytest зелёный), TC-17 (дедуп-регрессия), reviewer-чек.
## R-6 (P2) — Регрессия status-only verdict model аналитика (PR #12/#13)
**Описание.** Аналитик переходит в `In Review` И не должен auto-advance'иться — статус ждёт Approved/Rejected от стейкхолдера. Если переписывание `_build_analyst_ready_comment` на обёртку случайно вернёт `auto_advance=True` или поменяет content так, что человек не поймёт инструкцию — порвётся существующий контракт.
**Митигация.**
- TC-11 + TC-16: регрессионные тесты на формат коммента и status-only поведение.
- ADR-001 §1 явно фиксирует: контракт аналитика сохраняется; обёртка строит ИДЕНТИЧНЫЙ существующему текст + добавляет только строку длительности.
## R-7 (P3) — Локализация и кодировка emoji в HTML
**Описание.** В `src/usage.py` emoji-ы записаны `\Uxxxxxxxx`-escape'ами. При сборке HTML это безопасно (Python декодирует до utf-8), но при возможном последующем base64/quoted-printable транспорте могла бы возникнуть проблема. Plane API принимает utf-8 → риск минимален.
**Митигация.** Не требуется. Существующий путь (PR #13, аналитик) уже посылает emoji через тот же `add_comment` без проблем.
## R-8 (P3) — Дублирование YAML-парсинга frontmatter
**Описание.** ADR-001 §5 принимает дублирование (~10 строк) в `src/frontmatter.py` и оставляет `src/qg/checks.py` со своим парсером. При расхождении правил (например, мы научим `read_frontmatter_value` поддерживать `---\nkey: value\n---` без trailing newline, а `qg/checks.py` останется строгим) теоретически возможны несогласованные интерпретации.
**Митигация.** Принято в scope discipline; следующая задача-рефактор объединит. До тех пор — `read_frontmatter_value` обязан быть строго совместимым (по тестам) с поведением `qg/checks.py` на канонических случаях (BR-frontmatter с trailing newline после `---`).
## R-9 (P0) — НЕ перезапускать прод-контейнер `orchestrator`
**Описание.** Self-hosting: прод-контейнер (8500) обслуживает ВСЕ проекты (orchestrator + enduro-trails) из общей БД. Внеплановый рестарт ради «быстро посмотреть формат коммента» = простой конвейера всех проектов.
**Митигация.**
- Все эксперименты — на staging (8501) через `docker compose --profile staging up -d orchestrator-staging`.
- Прод-деплой только через стандартный путь `deploy-staging → deploy` (под надзором `check_staging_status`).
- ЗАПРЕЩЕНО при ручном тестировании коммента дёргать `docker compose restart orchestrator`.
---
## Открытые вопросы (Q&A — все закрыты ADR-001)
| Q | Вопрос | Решение | Где зафиксировано |
|---|--------|---------|-------------------|
| Q-1 | Куда девать usage-метрику (tokens/cost)? | Сохранить как `<sub>…</sub>` хвостом в том же комменте. | ADR-001 §3 |
| Q-2 | «Длительность:» или «Duration:»? | «Длительность:» (русский, соответствует остальным меткам). | ADR-001 §4 |
| Q-3 | Один общий хелпер или раздельные для analyst/прочих? | Один: `build_status_comment(...)`; analyst — ветка внутри. | ADR-001 §1 |
| Q-4 | Парсер frontmatter — переиспользовать `qg/checks.py` или новый? | Новый `src/frontmatter.py`; `qg/checks.py` НЕ трогаем в этом PR. | ADR-001 §5 |
| Q-5 | Контракт DB-фоллбэка длительности. | `get_agent_duration(task_id, agent) -> int | None`, см. SQL в ADR-001 §6. | ADR-001 §6 |
| Q-6 | HTML vs Markdown. | HTML (как у эталона); `artifact_links` переписывается на `<a>`. | ADR-001 §7 |
| Q-7 | Судьба старого `usage_comment(...)`. | Удалить, перевести единственного клиента (`_post_usage_comments`) на `build_status_comment`. | ADR-001 §1 |
Если developer на стадии development обнаружит, что R-5 материализуется (есть скрытый клиент `usage_comment`) — допустимо оставить `usage_comment` как 1-строчную deprecated-обёртку (`return build_status_comment(...)`) и зафиксировать факт в `12-review.md` без возврата в architecture.
---
*Risk register для ORCH-016. Обновляется reviewer'ом, если в ходе ревью всплывут новые риски — текущий список фиксирует видимое на момент завершения стадии architecture.*

View File

@@ -0,0 +1,120 @@
---
type: review
work_item_id: ORCH-016
verdict: APPROVED
version: 1
---
# Review ORCH-016 — Единый status-коммент агентов в Plane
## Summary
PR реализует ТЗ ORCH-016 и ADR-001 полностью: вводится единый хелпер
`src/usage.build_status_comment(...)` для всех ролей (analyst…deployer),
строка `Длительность: …` с явным `duration_s` от launcher и DB-фоллбэком для
аналитика, defensive YAML-парсер `src/frontmatter.read_frontmatter_value`,
HTML-формат с эмодзи / Verdict / Документы / `<sub>` тех-хвостом. Аналитик
переведён на ту же ветку без регрессии (`tests/test_analyst_comment.py` +
`tests/test_analyst_status_only_regression.py` зелёные). `usage_comment` стал
deprecated-обёрткой, `artifact_links` теперь возвращает HTML-фрагменты
(breaking-change только для внутреннего вызова из удаляемого пути).
Документация обновлена: CHANGELOG.md (`Added` + `Changed`),
`docs/architecture/README.md` (новый подраздел «Plane Sync: единый
status-коммент агентов»), ADR-001 заведён в
`docs/work-items/ORCH-016/06-adr/`.
Прохождение тестов:
- 60 новых ORCH-016 тестов: PASS (TC-01…TC-23 покрывают AC-1…AC-14).
- TC-20 (`test_qg_registry_snapshot.py`) подтверждает: `QG_CHECKS` и
`STAGE_TRANSITIONS` бит-идентичны (AC-11).
- Полный прогон: 392 PASS, 4 FAIL (`tests/test_m6_sequence.py::*`,
`tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo`,
`tests/test_plane_webhook.py::test_prefixes_independent_per_project`).
Эти 4 фейла **предсуществуют на `main`** (проверено: `git checkout main --
src/ tests/` → те же 4 фейла; ORCH-016 их не индуцировал). AC-10 «no
regression» соблюдено.
Соответствие ТЗ (`02-trz.md`):
- §1 модули: тронуты строго заявленные (`usage.py`, `stage_engine.py`,
`agents/launcher.py`, новый `frontmatter.py`); `qg/checks.py` сознательно
не трогается (ADR-001 §5, alt-6).
- §2.1§2.5 формат, описания, verdict, ссылки, duration — реализовано.
- §3 API не меняется; §4 БД не меняется; §5 новых QG нет — подтверждено
TC-20.
- §6 docstrings, graceful frontmatter / duration, `fmt_duration` — чистая,
AC-13 happy + edge кейсы зелёные.
- §7 артефакты: ADR заведён.
- §8 документация: README архитектуры и CHANGELOG обновлены, `CLAUDE.md`
не трогается (правила не меняются).
- §9 запреты: `QG_CHECKS` / `STAGE_TRANSITIONS` / `add_comment` /
`_headers_for` / `PLANE_BOT_TOKENS` не тронуты; `--no-verify` не
использован.
Соответствие ADR-001:
- §1 единственный публичный `build_status_comment(...)` с указанной
сигнатурой ✓
- §2 описания per-agent ✓
- §3 `<sub>` тех-хвост ✓
- §4 русская метка `Длительность:`
- §5 `src/frontmatter.py`
- §6 `get_agent_duration` с указанным SQL ✓
- §7 HTML-якоря, `<br>` разделители ✓
- §8 `fmt_duration` контракт ✓
Self-hosting (ADR-001 «Последствия»): хелперы — чистый код, без рестарта
прод-контейнера; пройдёт стандартный staging-гейт.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice to have
- `src/usage.py` `_AGENT_DESCRIPTIONS` и встроенные строки в
`build_status_comment` (например, `"Длительность: " f"{d_text}"` и
`"Завершил " "архитектурную " "проработку. " "См. ADR ниже."`) разбиты
на множественные смежные литералы. Python склеит их корректно, но
читаемость страдает — рассмотреть однострочный литерал в follow-up.
- `03-acceptance-criteria.md` AC-3 формулирует пример как
`verdict: APPROVE`, тогда как канонический QG (`check_reviewer_verdict`,
`src/qg/checks.py:306`) ожидает строго `verdict: APPROVED`. На
отображение коммента это не влияет (билдер показывает то, что лежит
во frontmatter), но в самом AC лучше было бы зафиксировать тот же
термин, что в QG. Чинить артефакт стадии analysis из стадии review —
out-of-scope (правило: «не править артефакты других этапов»);
оставляю как заметку на follow-up для аналитика.
- `_post_usage_comments` для `deployer` всегда (включая
`deploy-staging`) дополнительно постит `task_summary_comment`. ТЗ §2.6
и AC-7 явно это не запрещают (саммари не считается status-комментом),
и `tests/test_post_usage_comments_integration.py::test_deployer_staging_picks_15_log`
это поведение фиксирует. Поведение работает, но смысловой саммари
«Итого по задаче» на staging-стадии (задача не завершена) — слегка
ранний. Кандидат на уточнение требований в отдельной задаче.
## Документация
- `CHANGELOG.md` — раздел `Unreleased` дополнен записями `Added` и
`Changed` с упоминанием ORCH-016, `build_status_comment`,
`fmt_duration`, `get_agent_duration`, `src/frontmatter.py` и
ссылки на ADR. ✓
- `docs/architecture/README.md` — добавлен подраздел «Plane Sync:
единый status-коммент агентов (ORCH-016)» с описанием формата
HTML-блока, источниками длительности и вердиктов, явным указанием,
что реестр гейтов и стадий не меняется. ✓
- `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`
заведён, статус `Accepted`, покрывает все 5 открытых вопросов ТЗ
и пять альтернатив. ✓
- `CLAUDE.md` — правки не требовались (правила агентов и канон
документации без изменений), что и заявлено в ADR-001.
- `docs/architecture/internals.md` — упоминания про `usage.py` /
комменты не имеет, обновление не требуется (как и оговорено
ADR-001 §1).
Документация = golden source соблюдён: изменения в `src/` сопровождены
синхронным обновлением документации в том же PR.

View File

@@ -0,0 +1,159 @@
---
type: test-report
work_item_id: ORCH-016
verdict: PASS
result: PASS
version: 1
---
# Test Report — ORCH-016
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-016-plane`
- Ветка: `feature/ORCH-016-plane` @ `1778d8f` (reviewer auto-commit)
- Дата: 2026-06-05
- Prod-инстанс orchestrator: `/health``{"status":"ok"}` (не трогался)
## Команды
```bash
# Полный регресс из worktree
pytest tests/ -v --tb=short
# ORCH-016 целевой набор
pytest tests/test_status_comment_format.py \
tests/test_post_usage_comments_integration.py \
tests/test_status_comment_authorship.py \
tests/test_status_comment_dedup_regression.py \
tests/test_status_comment_duration_db_fallback.py \
tests/test_fmt_duration.py \
tests/test_qg_registry_snapshot.py \
tests/test_analyst_comment.py \
tests/test_analyst_comment_regression.py \
tests/test_analyst_status_only_regression.py \
tests/test_notify_done_regression.py -v
```
## Сводка
| Прогон | Passed | Failed | Skipped |
|--------|-------:|-------:|--------:|
| Полный (`tests/`) | **392** | **4** | 6 |
| ORCH-016 целевой (62 теста) | **62** | **0** | 0 |
## Smoke test API
| Endpoint | HTTP | Ответ |
|----------|------|-------|
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | 200 | JSON, активна задача `ORCH-016` (stage `testing`) |
| `GET /queue` | 200 | JSON, `counts={queued:0,running:1,done:36,failed:0}`, breaker `closed`, preflight OK |
## Покрытие плана тестов (`04-test-plan.yaml`)
| TC | Модуль | AC | Результат |
|----|--------|----|-----------|
| TC-01 | `test_status_comment_format.py::test_tc01_architect_comment` | AC-1 | PASS |
| TC-02 | `test_status_comment_format.py::test_tc02_developer_comment_links_branch_and_pr` | AC-2 | PASS |
| TC-03 | `test_status_comment_format.py::test_tc03_reviewer_verdict_approve` | AC-3 | PASS |
| TC-04 | `test_status_comment_format.py::test_tc04_reviewer_verdict_request_changes` | AC-3 | PASS |
| TC-05 | `test_status_comment_format.py::test_tc05_reviewer_missing_artifact_graceful` | AC-3, AC-8 | PASS |
| TC-06 | `test_status_comment_format.py::test_tc06_tester_pass` | AC-4 | PASS |
| TC-07 | `test_status_comment_format.py::test_tc07_tester_fail` + `test_tc07b_tester_falls_back_to_status_key` | AC-4 | PASS |
| TC-08 | `test_status_comment_format.py::test_tc08_deployer_deploy_status_success` + `test_deployer_status_failed_drives_status_line` | AC-5 | PASS |
| TC-09 | `test_status_comment_format.py::test_tc09_deployer_staging_status_success` | AC-5 | PASS |
| TC-10 | `test_status_comment_format.py::test_tc10_url_fallback_to_gitea_url` | AC-9 | PASS |
| TC-11 | `test_analyst_comment_regression.py::test_tc11_analyst_text_preserved_with_links` + `test_tc11_analyst_includes_duration_when_db_has_run` | AC-6 | PASS |
| TC-12 | `test_status_comment_format.py::test_tc12_frontmatter_*` (×4 кейса) | AC-8 | PASS |
| TC-13 | `test_post_usage_comments_integration.py::test_tc13_reviewer_posts_one_status_comment` | AC-3, AC-7 | PASS |
| TC-14 | `test_post_usage_comments_integration.py::test_tc14_tester_posts_one_status_comment` | AC-4, AC-7 | PASS |
| TC-15 | `test_post_usage_comments_integration.py::test_tc15_deployer_posts_status_then_summary` + `test_deployer_staging_picks_15_log` | AC-5, AC-7 | PASS |
| TC-16 | `test_analyst_status_only_regression.py::test_tc16_analyst_goes_to_in_review_no_advance` | AC-6 | PASS |
| TC-17 | `test_status_comment_dedup_regression.py::test_tc17_*` (×4) | AC-7 | PASS |
| TC-18 | `test_notify_done_regression.py::test_notify_done_*` + `test_orch016_does_not_steal_done_signal` (×4) | AC-10 | PASS |
| TC-19 | `test_status_comment_authorship.py::test_tc19_*` (×7) | AC-7 | PASS |
| TC-20 | `test_qg_registry_snapshot.py::test_tc20_qg_registry_unchanged` + `test_tc20_qg_callables_unchanged` + `test_tc20_stage_transitions_unchanged` | AC-11 | PASS |
| TC-21 | `test_fmt_duration.py::test_fmt_duration_boundary_table` | AC-13 | PASS |
| TC-22 | `test_fmt_duration.py::test_fmt_duration_none_returns_empty` + `test_fmt_duration_negative_returns_empty` + `test_fmt_duration_garbage_returns_empty` | AC-13 | PASS |
| TC-23 | `test_status_comment_format.py::test_tc23_no_duration_no_line` | AC-13, AC-14 | PASS |
| TC-24 | `test_status_comment_duration_db_fallback.py::test_tc24_*` (×5) + `test_explicit_duration_wins_over_db_fallback` | AC-14 | PASS |
| TC-25 | `test_status_comment_duration_db_fallback.py::test_tc25_db_read_failure_no_raise` | AC-14 | PASS |
**Итого: 25/25 TC = PASS** (на 25 ID плана приходится 62 фактических теста; все зелёные.)
## Сопоставление с критериями (`03-acceptance-criteria.md`)
| AC | Покрытие | Результат |
|----|----------|-----------|
| AC-1 Architect comment | TC-01 + `test_ac1_architect_header_literal` | PASS |
| AC-2 Developer comment | TC-02 | PASS |
| AC-3 Reviewer verdict | TC-03, TC-04, TC-05, TC-13 | PASS |
| AC-4 Tester verdict | TC-06, TC-07, TC-14 | PASS |
| AC-5 Deployer status | TC-08, TC-09 + `test_ac5_deployer_deploy_description` + `test_ac5_deployer_staging_description` + TC-15 | PASS |
| AC-6 Analyst no regression | TC-11, TC-16 | PASS |
| AC-7 Один коммент на агента | TC-13, TC-14, TC-15, TC-17, TC-19 | PASS |
| AC-8 Graceful fallback артефакта | TC-05, TC-12 | PASS |
| AC-9 `gitea_public_url` | TC-10 | PASS |
| AC-10 Зелёные существующие тесты | Регрессии нет (см. ниже) | PASS |
| AC-11 QG / STAGE_TRANSITIONS неизменны | TC-20 (×3) | PASS |
| AC-12 Документация обновлена | Reviewer верифицировал в `12-review.md` (CHANGELOG, architecture/README, ADR-001) | PASS |
| AC-13 `fmt_duration` формат | TC-21, TC-22, TC-23 | PASS |
| AC-14 Длительность fallback | TC-24, TC-25 | PASS |
**AC-1…AC-14 = PASS.**
## Анализ 4 фейлов в полном прогоне (AC-10)
```
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project
```
Эти 4 фейла — **предсуществующая регрессия на `main`**, не индуцированная ORCH-016. Проверка:
```
$ git clone -b main /repos/orchestrator /tmp/orch-main-check
$ cd /tmp/orch-main-check
$ pytest tests/test_m6_sequence.py tests/test_plane_webhook.py
==================== 4 failed, 7 passed, 1 warning in 0.80s ====================
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project
```
На свежем клоне `main` те же 4 теста падают с идентичными сообщениями (`assert None is not None`, `KeyError: 'o1'`). ORCH-016 не трогает `src/webhooks/plane.py`, `src/plane_sync.py::fetch_issue_sequence_id`, `src/projects.py` — то есть участки, ответственные за эти кейсы. Reviewer ранее зафиксировал тот же факт в `12-review.md`. **Регрессий, индуцированных ORCH-016 = 0** → AC-10 PASS.
Эти 4 фейла должны быть подняты отдельной задачей (вне scope ORCH-016).
## Вывод pytest (хвост полного прогона)
```
=========================== short test summary info ============================
FAILED tests/test_m6_sequence.py::test_created_uses_plane_sequence_id - asser...
FAILED tests/test_m6_sequence.py::test_created_falls_back_to_db_when_plane_down
FAILED tests/test_plane_webhook.py::test_orchestrator_project_routes_to_orchestrator_repo
FAILED tests/test_plane_webhook.py::test_prefixes_independent_per_project - K...
============ 4 failed, 392 passed, 6 skipped, 13 warnings in 7.44s =============
```
## Self-hosting
Прод-контейнер `orchestrator` (порт 8500) во время прогонов не перезапускался, не ронялся: `/health` → ok, `/queue` → breaker closed, текущая задача `ORCH-016` (running) в очереди. Тесты выполнялись в worktree-копии `feature_ORCH-016-plane`, не затрагивая прод-БД.
## Итог
**PASS.**
- Все 25 TC из `04-test-plan.yaml` = PASS (62 фактических теста зелёные).
- Все 14 AC из `03-acceptance-criteria.md` = PASS.
- Регрессий относительно `main` нет (4 хронических фейла предсуществуют, см. выше).
- Smoke test API зелёный.
- Прод-инстанс не задет.
Задача готова к стадии `deploy-staging`.

View File

@@ -0,0 +1,145 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-05T12:51:07Z
work_item: ORCH-016
branch: feature/ORCH-016-plane
commit: d4b02ef728521776ac13dbed39ac64a758d9de54
target_service: orchestrator
target_port: 8500
deploy_mode: artifact-only
prod_container_restarted: false
---
# Deploy Log — ORCH-016
## Verdict
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
Реальный pull / docker-restart прод-контейнера `orchestrator` (8500) НЕ
выполняется в рамках этой стадии: он делегирован хуку
`scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
после мерджа PR ветки `feature/ORCH-016-plane` в `main`.
## Pre-conditions (все ✓)
| Артефакт | Поле | Значение |
|----------|------|----------|
| `12-review.md` | `verdict` | `APPROVED` |
| `13-test-report.md` | `verdict` | `PASS` |
| `15-staging-log.md` | `staging_status` | `SUCCESS` (10/10 staging-checks) |
| `04-test-plan.yaml` | — | покрывает AC-1…AC-14 |
| ADR | `06-adr/ADR-001-*` | заведён |
| CHANGELOG.md | `Added`/`Changed` | обновлён в коммите `0663da6` |
## Self-hosting policy
> ORCH-016 правит код инструмента, который СЕЙЧАС обслуживает все
> проекты (orchestrator + enduro-trails) из одного прод-инстанса
> (`orchestrator:8500`) с общей БД и общей очередью.
Поэтому:
1. **Прод-контейнер `orchestrator` (8500) в этой стадии НЕ
перезапускался** — `prod_container_restarted: false` в frontmatter.
Это прямое требование `CLAUDE.md` (раздел "Self-hosting") и
`docs/operations/INFRA.md`.
2. Перезапуск прод-контейнера произойдёт ПОЗЖЕ, после мерджа ветки в
`main` и срабатывания CI → `scripts/orchestrator-deploy-hook.sh`.
3. Staging-стенд (8501) уже принял изменения и прошёл регресс
(`15-staging-log.md`, 10/10 checks) — это и есть страховка перед
прод-деплоем self.
## Что войдёт в прод после мерджа PR
Изменения ORCH-016 (коммит `0663da6` + reviewer/tester auto-commits):
| Файл | Тип изменения |
|------|---------------|
| `src/usage.py` | расширен `build_status_comment(...)`: длительность, defensive формат, HTML-фрагменты `artifact_links` |
| `src/agents/launcher.py` | пробрасывает `duration_s` из `_monitor_agent` в `_post_usage_comments` |
| `src/stage_engine.py` | для analyst-стадии — DB-fallback `usage.get_agent_duration(task_id, agent)` |
| `src/frontmatter.py` | defensive `read_frontmatter_value(...)` |
| `tests/test_status_comment_*.py` и др. | 60 новых тестов TC-01…TC-23 (PASS) |
| `docs/architecture/README.md` | раздел "Plane Sync: единый status-коммент агентов" |
| `docs/work-items/ORCH-016/06-adr/ADR-001-*.md` | ADR ORCH-016 |
| `CHANGELOG.md` | `Added` + `Changed` |
Поведение, видимое в Plane после прод-деплоя: единый формат финального
status-комментария у всех ролей (analyst…deployer), с явной строкой
`Длительность: …` и HTML-форматом артефактных ссылок.
## Deploy-handoff (что будет дальше, вне этой стадии)
После того как PR с веткой `feature/ORCH-016-plane` будет смерджен в
`main`, цепочка такая (см. `scripts/orchestrator-deploy-hook.sh`):
```
PR merge to main
└─► Gitea Actions (CI)
└─► orchestrator-deploy-hook.sh --deploy
├─ git pull origin main
├─ docker compose up -d --no-build orchestrator (TARGET_SERVICE=orchestrator, TARGET_PORT=8500)
├─ health-check 10× × 6s (max 60s)
└─ at failure → AUTO ROLLBACK to previous image
```
Параметры прод-деплоя, которые должны быть выставлены в окружении
hookа (env vars из `INFRA.md`):
```
TARGET_SERVICE=orchestrator
TARGET_PORT=8500
TARGET_IMAGE=orchestrator-orchestrator
COMPOSE_PROFILE="" # пустой → без --profile, дефолтный сервис
PREV_IMAGE_FILE=$REPO/.deploy-prev-image-prod
```
(Дефолты в скрипте — STAGING-safe; прод-параметры выставляет внешний
caller, не агент.)
Auto-rollback hookа гарантирует, что в случае нездорового deploy
контейнер вернётся на предыдущий образ, а строка `deploy_status` в этом
логе НЕ задним числом меняется — финальный прод-вердикт фиксируется
отдельным запуском стадии `deploy` после ORCH-36 GA.
## Команды (только read-only проверки, ничего не запускалось)
```bash
# 1. Подтвердить, что прод-инстанс живой (не трогаем, только смотрим):
# выполнялось окружением (curl недоступен в worktree-sandbox),
# последний подтверждённый /health=ok — в 13-test-report.md.
# 2. Подтвердить вердикт staging:
grep '^staging_status:' docs/work-items/ORCH-016/15-staging-log.md
# → staging_status: SUCCESS
# 3. Подтвердить вердикты review/test:
grep -E '^(verdict|result):' docs/work-items/ORCH-016/{12-review.md,13-test-report.md}
# → 12-review.md:verdict: APPROVED
# → 13-test-report.md:verdict: PASS
# → 13-test-report.md:result: PASS
```
## Rollback plan (если по факту прод-деплоя что-то сломается)
1. Hook сам делает auto-rollback (см. `do_rollback()` в
`orchestrator-deploy-hook.sh`).
2. Ручной откат — вызвать:
```bash
TARGET_SERVICE=orchestrator TARGET_PORT=8500 \
TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE="" \
PREV_IMAGE_FILE=/home/slin/repos/orchestrator/.deploy-prev-image-prod \
/home/slin/repos/orchestrator/scripts/orchestrator-deploy-hook.sh --rollback
```
3. Точка отката: предыдущий running image, сохранённый в
`.deploy-prev-image-prod` ДО `docker compose up`.
## Quality Gate
Поле `deploy_status: SUCCESS` (uppercase) в YAML-frontmatter этого файла —
машинно-читаемый вердикт, который парсит quality gate
`check_deploy_status`. Никакая проза в теле логa не учитывается.
---
*Stage: `deploy`. Финальная стадия конвейера. Следующий шаг — `done` (закрывается CI / финальной стадией, не агентом). Self-hosting: prod-контейнер `orchestrator:8500` в рамках этой стадии не трогался — это прямое требование `CLAUDE.md`.*

View File

@@ -0,0 +1,97 @@
---
staging_status: SUCCESS
timestamp: 2026-06-05T12:47:48Z
base_url: http://localhost:8501
work_item: ORCH-016
branch: feature/ORCH-016-plane
mode: stub
---
# Staging Gate Log — ORCH-016
## Verdict
**`staging_status: SUCCESS`** — staging test suite completed, all 10/10 checks PASS.
## Окружение
- **Base URL:** `http://localhost:8501` (orchestrator-staging)
- **Mode:** `stub` (без LLM-spend; проверяет ранние артефакты pipeline — branch + queued analyst job)
- **Suite:** `scripts/staging_check.py` (ORCH-33)
- **Sandbox project:** `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (ORCH Sandbox)
- **Repo под тест:** `orchestrator-sandbox`
## Результаты (10/10 PASS)
### Block A — SMOKE
| ID | Проверка | Результат |
|----|----------|-----------|
| A1 | `GET /health` → 200, `status=ok` | ✓ PASS |
| A2 | `GET /queue` → 200, ключи `counts/max_concurrency/resilience` | ✓ PASS |
| A3 | `ORCH_STAGING=true` (защита от прод-окружения) | ✓ PASS |
### Block B — ACCESS
| ID | Проверка | Результат |
|----|----------|-----------|
| B4 | Plane: sandbox project accessible (5 projects, sandbox=YES) | ✓ PASS |
| B5 | Gitea: `orchestrator-sandbox` доступен, `push=true` | ✓ PASS |
| B6 | Registry: sandbox в known IDs, prod ET/ORCH отсутствуют | ✓ PASS |
### Block C — E2E (mode=stub)
| ID | Проверка | Результат |
|----|----------|-----------|
| C7 | Create issue in Plane SANDBOX → HTTP 201, `issue_id=37d91fba-5ac1-460b-ab06-a13f963911bc` | ✓ PASS |
| C8 | Trigger pipeline via `POST /webhook/plane` (с HMAC) → HTTP 200, `status=accepted` | ✓ PASS |
| C9a | Branch появилась в `orchestrator-sandbox``feature/SANDBOX-009-staging-check-e2e-20260605t124` | ✓ PASS |
| C9b | Analyst job в очереди staging (`/queue` → recent) → `job_id=5, status=queued, agent=analyst` | ✓ PASS |
### Cleanup
- Удалена тестовая ветка в Gitea (HTTP 204).
- Удалён тестовый Plane issue (HTTP 204).
- DB-cleanup: task row отсутствовал (нормально для stub-mode), dedup-таблица отсутствует (некритично).
## Что значит "SUCCESS" для ORCH-016
ORCH-016 — это унификация финальных коммент-логов агентов (`usage.build_status_comment` + длительность). Изменения затрагивают:
- `src/usage.py` — расширен билдер коммента (длительность, defensive формат).
- `src/agents/launcher.py` — пробрасывает `duration_s` из `_monitor_agent` в `_post_usage_comments`.
- `src/stage_engine.py` — для analyst-стадии использует DB-fallback `usage.get_agent_duration(task_id, agent)`.
- `src/frontmatter.py` — defensive `read_frontmatter_value(...)`.
Staging-стенд (orchestrator-staging) поднят на актуальном образе и:
1. Принимает Plane-webhook (HMAC OK).
2. Корректно фильтрует проекты через registry (B6 — sandbox разрешён, прод ET/ORCH отрезаны).
3. Дотягивает pipeline до постановки analyst job в персистентную очередь (ORCH-1) и создания ветки в Gitea.
Поведение коммент-логов в реальном e2e (mode=full-real) НЕ проверялось — это требует LLM-spend и реального запуска агентов. В рамках staging-gate для ORCH-016 это считается достаточным: финальный коммент строится из артефактов (`12-review.md`, `13-test-report.md`, ...) и uses-данных из `agent_runs`, которые уже покрыты unit-тестами в `tests/`.
## Откат не требуется
Все 10 проверок зелёные → переход на стадию `deploy` разрешён. Прод-контейнер `orchestrator` (8500) в рамках этой стадии НЕ перезапускался (правило self-hosting, `CLAUDE.md`).
## Команда запуска (для воспроизведения)
```bash
# Загрузить .env.staging БЕЗ shell-source (JSON-значения ломают bash):
python3 -c "
import os, subprocess
env = dict(os.environ)
with open('/repos/orchestrator/.env.staging') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, _, v = line.partition('=')
env[k.strip()] = v.strip()
r = subprocess.run(
['python3', 'scripts/staging_check.py',
'--base-url', 'http://localhost:8501', '--mode', 'stub'],
env=env,
)
exit(r.returncode)
"
```
---
*Stage: `deploy-staging` → `deploy`. Quality Gate `check_staging_status` ожидает `staging_status: SUCCESS` в frontmatter этого файла.*

View File

@@ -0,0 +1,7 @@
# Business Request: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве
Work Item ID: ORCH-017
## Description
TBD

View File

@@ -0,0 +1,91 @@
# 01-BRD — ORCH-017: Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве
Work Item: **ORCH-017**
Repo: `orchestrator` · Branch: `feature/ORCH-017-brd-plane-telegram`
Тип: косметическая правка (UX уведомлений). Парная с ORCH-016.
## 1. Бизнес-контекст и проблема
Когда оркестратор завершает стадию `analysis` и просит подтвердить BRD, в Telegram уходит
отдельное «пингующее» уведомление (`notify_approve_requested` в `src/notifications.py`).
Сейчас в этом сообщении **нет ссылок**: владелец (Слава) вынужден вручную зайти в Plane,
найти нужную issue, открыть комментарий аналитика, оттуда перейти к BRD-документу. Это
лишние ручные шаги на каждой задаче.
Текущий текст уведомления:
> 📋 {WI}: BRD/ТЗ/AC готовы. Переведите задачу в статус Approved в Plane для продолжения.
## 2. Цель
В **этом же** уведомлении дать две прямые кликабельные ссылки, чтобы весь сценарий
прохождения апрува выполнялся из Telegram, без ручной навигации в Plane:
1. **Ссылка на BRD** — открывает `01-brd.md` в Gitea (прочитать документ).
2. **Ссылка на Plane-issue** — открывает задачу в Plane (перевести в Approved / отклонить с комментом).
## 3. Целевой сценарий (Слава)
Получил уведомление → кликнул «📄 BRD» → прочитал → кликнул «✅ Задача» → перевёл в
Approved (или отклонил с комментарием). Всё из Telegram.
## 4. Объём (Scope)
### В объёме (выбранный по умолчанию минимальный вариант — см. §8 открытые вопросы)
- Доработка **только** функции `notify_approve_requested(task_id)` в `src/notifications.py`
(стадия `analysis`, запрос статуса Approved).
- Формирование двух ссылок и встраивание их в текст того же отдельного уведомления.
- Формат — HTML-ссылки в тексте (`<a href="…">label</a>`), т.к. `send_telegram` уже шлёт
`parse_mode="HTML"`. Альтернатива (inline-кнопки) — открытый вопрос §8.
- Новая конфиг-настройка для внешнего web-URL Plane (см. §6, риск №1).
- Обновление документации (`CLAUDE.md` env-карта при необходимости, `CHANGELOG.md`,
`.env.example`) в том же PR.
### Вне объёма (НЕ трогать)
- Логика апрува: `:approved:`-handler, `check_analysis_approved`, переходы стадий.
- Живой Telegram-трекер (`update_task_tracker` / `render_task_tracker`, PR #21/#22) — его
текст и поведение не меняем; новое уведомление остаётся ОТДЕЛЬНЫМ сообщением, дубли
трекера не создаём.
- Содержимое комментариев в Plane (это смежная задача ORCH-016).
- Ссылки в других уведомлениях (deploy-failed, agent-failed, error) — вне объёма по
умолчанию (см. открытый вопрос §8.2).
## 5. Заинтересованные стороны
- **Owner / получатель уведомления:** Слава.
- **Поставщик данных:** оркестратор (БД `tasks`: repo, branch, work_item_id, plane_issue_id).
## 6. Функциональные требования
| # | Требование |
|---|------------|
| FR-1 | Уведомление об апруве BRD содержит кликабельную ссылку на документ `docs/work-items/<WI>/01-brd.md` в Gitea. |
| FR-2 | То же уведомление содержит кликабельную ссылку на соответствующую Plane-issue. |
| FR-3 | Существующий текст-призыв («Переведите задачу в статус Approved …») сохраняется. |
| FR-4 | Уведомление остаётся ОДНИМ отдельным пингующим сообщением (без дублей, без второго сообщения). |
| FR-5 | Ссылка на BRD строится на внешнем `gitea_public_url` (фоллбэк `gitea_url`), формат branch-view: `{base}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{WI}/01-brd.md`. Переиспользовать существующий паттерн из `src/usage.py`. |
| FR-6 | Ссылка на Plane-issue строится на внешнем web-URL Plane + workspace + project + issue. |
## 7. Нефункциональные требования
| # | Требование |
|---|------------|
| NFR-1 | **Никогда не ронять оркестратор** из-за уведомления: построение ссылок обёрнуто в защиту, при отсутствии данных (нет branch / нет plane_issue_id / не задан web-URL) — сообщение всё равно отправляется, просто без соответствующей ссылки (graceful degradation). |
| NFR-2 | Не нарушать self-hosting: правка не требует рестарта прод-контейнера сверх обычного деплоя; не меняет реестр гейтов/стадий. |
| NFR-3 | Сохранить `parse_mode="HTML"`; экранировать динамические подписи (`html.escape`), URL формировать из доверенных конфиг-значений. |
## 8. Открытые вопросы (требуют решения Owner; в документах принят безопасный дефолт)
1. **Формат ссылок.** Дефолт BRD: HTML-ссылки в тексте (минимальная правка). Альтернатива —
inline-кнопки «📄 Открыть BRD» / «✅ К задаче в Plane», что требует доработки `send_telegram`
(параметр `reply_markup`/`inline_keyboard`). → решение к стадии architecture.
2. **Охват.** Дефолт: только BRD-апрув (`notify_approve_requested`). Альтернатива — все точки,
требующие решения Славы (напр. согласование макета ORCH-14). → если «все точки», объём
расширяется, нужен отдельный перечень событий.
3. **Внешний web-URL Plane.** В конфиге сейчас только внутренний `plane_api_url`
(`http://localhost:8091`) — он НЕ годится для браузерной ссылки. Дефолт: завести новую
env-настройку `ORCH_PLANE_WEB_URL` (внешний адрес Plane) с фоллбэком на `plane_api_url`.
Точное значение URL должен подтвердить Owner/INFRA.
4. **Формат Plane-ссылки.** `…/{workspace}/projects/{project_id}/issues/{issue_id}/` (надёжно,
issue_id есть в `tasks.plane_issue_id`) vs короткий `…/{workspace}/browse/<IDENT>/`
(зависит от соответствия `work_item_id` ↔ Plane identifier, что не гарантировано из-за
zero-padding ORCH-017 vs ORCH-17). → решение к стадии architecture.
## 9. Зависимости и связки
- **PR #14** — `gitea_public_url`: переиспользуем для кликабельных ссылок на доки.
- **PR #21/#22** — живой Telegram-трекер: новое сообщение остаётся отдельным, трекер не трогаем.
- **ORCH-016** — единые коммент-артефакты в Plane (парная задача про навигацию к документам).
## 10. Критерий бизнес-успеха
Слава из одного Telegram-уведомления одним кликом открывает BRD и одним кликом — задачу в
Plane, не заходя в Plane вручную и не ища комментарий.

View File

@@ -0,0 +1,87 @@
# 02-ТЗ — ORCH-017: Прямые ссылки в Telegram-уведомлении об апруве BRD
Work Item: **ORCH-017** · Repo: `orchestrator`
Опирается на 01-brd.md. Уточняет конкретные изменения кода/конфигурации.
> Примечание по канону: ТЗ фиксирует ТРЕБОВАНИЯ к изменениям, а не готовое
> архитектурное решение. Выбор формата (текст vs inline-кнопки) и точного формата
> Plane-URL — за стадией architecture (см. открытые вопросы 01-brd.md §8). Если по
> ходу разработки ТЗ окажется неполным/неверным — возврат на стадию Анализ, без
> правок ТЗ задним числом.
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче |
|--------|---------------|
| `src/notifications.py` | **Основной.** Функция `notify_approve_requested(task_id)` (≈ строки 547566) — единственная точка отправки пингующего уведомления об апруве BRD. Сюда добавляются ссылки. |
| `src/config.py` | Класс `Settings`. Добавить настройку внешнего web-URL Plane (`plane_web_url`, env `ORCH_PLANE_WEB_URL`) с дефолтом-фоллбэком. |
| `src/projects.py` | (Чтение) `get_project_by_repo(repo)``plane_project_id` для построения Plane-URL. |
| `src/usage.py` | (Референс, не править) Эталонный паттерн branch-view ссылки на доки (`{base}/{owner}/{repo}/src/branch/{branch}/<rel>`), строки ≈483503 — переиспользовать тот же формат. |
| `src/db.py` | (Чтение) Таблица `tasks`: поля `work_item_id`, `repo`, `branch`, `plane_issue_id`. Источник данных для ссылок. |
## 2. Источники данных (из `tasks` по `task_id`)
- `work_item_id` — путь к BRD-документу и (опц.) идентификатор issue.
- `repo`, `branch` — построение Gitea branch-view URL.
- `plane_issue_id` — uuid issue в Plane для прямой ссылки.
- `project_id` — через `projects.get_project_by_repo(repo).plane_project_id`.
`notify_approve_requested` сейчас принимает только `task_id` и тянет лишь `work_item_id`
через `_get_work_item_id`. Требуется дополнительно прочитать `repo`, `branch`,
`plane_issue_id` из `tasks` (один SELECT, в защищённом try/except).
## 3. Требуемые изменения
### 3.1 `src/notifications.py`
- Построить **BRD-ссылку** (FR-1/FR-5):
`{base}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{work_item_id}/01-brd.md`,
где `base = (settings.gitea_public_url or settings.gitea_url).rstrip('/')`,
`owner = settings.gitea_owner`. Если нет `base`/`repo`/`branch`/`work_item_id` — ссылку
опустить (NFR-1).
- Построить **Plane-ссылку** (FR-2/FR-6):
`{plane_web_base}/{workspace_slug}/projects/{project_id}/issues/{plane_issue_id}/`
(точный формат — решение architecture, см. 01-brd §8.4). Если нет данных — опустить.
- Встроить обе ссылки в текст того же сообщения (FR-3/FR-4), формат HTML-`<a>` по умолчанию.
Сохранить существующий призыв «Переведите задачу в статус Approved …».
- Сохранить вызов как **одно** `send_telegram(msg)` (пингующее, не silent). Порядок
существующих действий не менять: старт BRD-часов (`mark_brd_review_started`) →
`update_task_tracker(task_id)``send_telegram(msg)`.
- Динамические подписи экранировать `html.escape` (NFR-3).
### 3.2 `src/config.py`
- Добавить в `Settings` поле `plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`).
- Семантика фоллбэка: `plane_web_base = (settings.plane_web_url or settings.plane_api_url).rstrip('/')`.
### 3.3 Опционально (если выбран вариант inline-кнопок — открытый вопрос 01-brd §8.1)
- Расширить `send_telegram(text, disable_notification=False, reply_markup=None)`:
при наличии `reply_markup` прокидывать его в payload `sendMessage`. Обратная
совместимость — обязательна (текущие вызовы без аргумента работают как раньше).
- ⚠️ Это РАСШИРЯЕТ объём; включается только по явному решению Owner на стадии architecture.
## 4. Изменения API
Нет. Публичные HTTP-эндпоинты (`/webhook/*`, `/status`, `/queue`, `/health`) не затрагиваются.
## 5. Изменения схемы БД
Нет. Все нужные поля (`repo`, `branch`, `work_item_id`, `plane_issue_id`) уже существуют в `tasks`.
## 6. Изменения конфигурации / окружения
- Новая env-переменная `ORCH_PLANE_WEB_URL` (внешний web-адрес Plane). Прописать в
`.env.example` (канон секретов/настроек), описать в env-карте (`CLAUDE.md` /
`docs/operations/INFRA.md`). Реальное значение задаётся в `.env`/`.env.staging` на хосте.
- Существующие `ORCH_GITEA_PUBLIC_URL`, `ORCH_GITEA_OWNER`, `ORCH_PLANE_WORKSPACE_SLUG`
переиспользуются как есть.
## 7. Требования к новым QG checks
Нет. Реестр `QG_CHECKS`, стадии и машинные вердикты не меняются (правка — отображение,
не управление конвейером).
## 8. Артефакты pipeline, которые должны быть обновлены в ЭТОМ PR
- `CHANGELOG.md` — запись о фиче.
- `.env.example` — новая `ORCH_PLANE_WEB_URL`.
- При добавлении настройки — env-карта в `CLAUDE.md` / `docs/operations/INFRA.md`.
- ADR (стадия architecture): `docs/work-items/ORCH-017/06-adr/ADR-001-*.md` — фиксирует выбор
формата (текст vs кнопки) и формат Plane-URL.
## 9. Ограничения
- Не трогать `:approved:`-handler и `check_analysis_approved` (только текст/формат уведомления).
- Не плодить сообщения: одно отдельное пингующее сообщение; живой трекер (PR #21/#22) не дублировать.
- Соблюдать self-hosting: не ронять/не рестартить прод сверх штатного деплоя; обязательная
страховка `deploy-staging` (8501) перед прод-деплоем орка.

View File

@@ -0,0 +1,64 @@
# 03-Acceptance Criteria — ORCH-017
Work Item: **ORCH-017** · Repo: `orchestrator`
Каждый критерий формулирует условие PASS/FAIL. Источник — 01-brd.md / 02-trz.md.
## AC-1 — Ссылка на BRD присутствует в уведомлении
- **PASS:** Текст, сформированный `notify_approve_requested`, содержит кликабельную ссылку
на `docs/work-items/<WI>/01-brd.md` вида
`{gitea_public_url|gitea_url}/{owner}/{repo}/src/branch/{branch}/docs/work-items/{WI}/01-brd.md`.
- **FAIL:** Ссылки на BRD нет, либо она ведёт не на `01-brd.md`/не на нужный WI.
## AC-2 — Ссылка на Plane-issue присутствует в уведомлении
- **PASS:** Тот же текст содержит кликабельную ссылку на issue в Plane, построенную на
внешнем web-URL Plane + workspace + project + `plane_issue_id` (или согласованный браузер-формат).
- **FAIL:** Ссылки на issue нет, либо она указывает на внутренний `localhost`/неверную issue.
## AC-3 — Базовый URL берётся из внешних настроек
- **PASS:** BRD-ссылка использует `gitea_public_url`, при его пустоте — `gitea_url`; Plane-ссылка
использует `plane_web_url` (env `ORCH_PLANE_WEB_URL`), при пустоте — `plane_api_url`.
- **FAIL:** Захардкожен хост, либо ссылка нерабочая снаружи деплой-хоста.
## AC-4 — Существующий призыв сохранён
- **PASS:** Текст по-прежнему содержит призыв перевести задачу в статус Approved (смысл строки
«Переведите задачу в статус Approved … для продолжения» сохранён).
- **FAIL:** Призыв удалён/искажён.
## AC-5 — Одно отдельное пингующее сообщение, без дублей
- **PASS:** `notify_approve_requested` отправляет ровно одно сообщение через `send_telegram`
(пингующее, не silent). Живой трекер (`update_task_tracker`) обновляется как раньше и не
дублируется новым сообщением.
- **FAIL:** Появляется второе/дубль-сообщение, либо трекер шлётся повторно как новое сообщение.
## AC-6 — Graceful degradation (никогда не ронять оркестратор)
- **PASS:** При отсутствии `branch` / `plane_issue_id` / незаданном Plane web-URL функция НЕ
бросает исключение: уведомление уходит с доступными ссылками (или без отсутствующей), орк жив.
- **FAIL:** Отсутствие данных приводит к исключению/падению потока уведомлений.
## AC-7 — HTML-безопасность
- **PASS:** Сохранён `parse_mode="HTML"`; динамические подписи экранируются (`html.escape`),
URL валиден и не ломает разметку сообщения.
- **FAIL:** Сообщение приходит с битой HTML-разметкой или с неэкранированным пользовательским текстом.
## AC-8 — Логика апрува не затронута
- **PASS:** `:approved:`-handler, `check_analysis_approved`, переходы стадий и реестр `QG_CHECKS`
без изменений; правка касается только текста/формата уведомления.
- **FAIL:** Изменена логика гейта/перехода стадий.
## AC-9 — Документация обновлена в том же PR
- **PASS:** Обновлены `CHANGELOG.md` и `.env.example` (новая `ORCH_PLANE_WEB_URL`); если добавлена
настройка — отражено в env-карте (`CLAUDE.md`/`docs/operations/INFRA.md`); заведён ADR на
выбранный формат. (Reviewer проверяет доку → нет обновления = REQUEST_CHANGES.)
- **FAIL:** Код изменён, документация — нет.
## AC-10 — Тесты зелёные
- **PASS:** Новые/затронутые тесты (`tests/test_notify_approve_links.py` и существующие
`tests/test_telegram_tracker.py`, `tests/test_notify_done_regression.py`) проходят; `pytest tests/ -q` зелёный.
- **FAIL:** Любой связанный тест падает.
---
### Зависит от решений Owner (open questions 01-brd §8)
- Если выбран вариант **inline-кнопок** — AC-1/AC-2 считаются выполненными при наличии кнопок
«📄 Открыть BRD» / «✅ К задаче в Plane» с теми же URL; дополнительно AC: обратная совместимость
`send_telegram` (старые вызовы без `reply_markup` работают).
- Если охват расширен до **всех точек решения** — AC-1/AC-2 проверяются для каждой такой точки.

View File

@@ -0,0 +1,99 @@
work_item: ORCH-017
title: "Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве"
notes: >
Тесты изолируют сеть: send_telegram/httpx мокируются, проверяется СФОРМИРОВАННЫЙ текст
(и/или reply_markup, если выбран вариант кнопок), а не реальная отправка. БД tasks
наполняется фикстурой (work_item_id, repo, branch, plane_issue_id). Маппинг на критерии — в поле acceptance.
tests:
- id: TC-01
type: unit
description: "notify_approve_requested формирует текст с кликабельной ссылкой на 01-brd.md (Gitea branch-view)"
module: tests/test_notify_approve_links.py
setup: "task в tasks с work_item_id=ORCH-017, repo=orchestrator, branch=feature/ORCH-017-..., gitea_public_url задан; send_telegram замокан"
expected: PASS
acceptance: [AC-1, AC-3]
- id: TC-02
type: unit
description: "Текст содержит ссылку на Plane-issue с внешним web-URL + workspace + project + plane_issue_id"
module: tests/test_notify_approve_links.py
setup: "plane_web_url(ORCH_PLANE_WEB_URL) и workspace заданы; project резолвится по repo; plane_issue_id в tasks"
expected: PASS
acceptance: [AC-2, AC-3]
- id: TC-03
type: unit
description: "При пустом gitea_public_url BRD-ссылка строится на gitea_url (фоллбэк); при пустом plane_web_url — на plane_api_url"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-3]
- id: TC-04
type: unit
description: "Сохранён призыв перевести задачу в статус Approved (подстрока 'Approved' присутствует)"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-4]
- id: TC-05
type: unit
description: "send_telegram вызван ровно один раз (пингующее сообщение), без disable_notification=True"
module: tests/test_notify_approve_links.py
setup: "mock send_telegram, assert call_count == 1 и аргумент disable_notification не True"
expected: PASS
acceptance: [AC-5]
- id: TC-06
type: unit
description: "Graceful: branch=None / plane_issue_id=None — функция не бросает исключение, сообщение всё равно отправляется"
module: tests/test_notify_approve_links.py
setup: "task без branch и без plane_issue_id; убедиться что send_telegram всё равно вызван, отсутствующая ссылка опущена"
expected: PASS
acceptance: [AC-6]
- id: TC-07
type: unit
description: "Plane web-URL не задан и plane_api_url пуст — Plane-ссылка опускается, BRD-ссылка остаётся, орк не падает"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-6]
- id: TC-08
type: unit
description: "Сохранён parse_mode=HTML; динамические подписи экранированы, HTML-разметка ссылок валидна"
module: tests/test_notify_approve_links.py
expected: PASS
acceptance: [AC-7]
- id: TC-09
type: unit
description: "Регрессия трекера: update_task_tracker по-прежнему работает (silent edit), новое сообщение его не дублирует"
module: tests/test_telegram_tracker.py
expected: PASS
acceptance: [AC-5, AC-8]
- id: TC-10
type: integration
description: "Поток analysis-approved: _handle_analysis_approved_flow при готовых артефактах вызывает notify_approve_requested; БД tasks даёт корректные repo/branch/plane_issue_id для ссылок"
module: tests/test_analysis_approve_flow_links.py
setup: "замокать сетевые вызовы Plane/Gitea/Telegram; убедиться, что check_analysis_approved/переходы стадий не изменены"
expected: PASS
acceptance: [AC-1, AC-2, AC-8]
# Условные тесты — включаются ТОЛЬКО если Owner выбрал вариант inline-кнопок (01-brd §8.1)
- id: TC-11
type: unit
description: "(Условный) Вариант кнопок: payload содержит reply_markup.inline_keyboard с кнопками '📄 Открыть BRD' и '✅ К задаче в Plane' с верными url"
module: tests/test_notify_approve_links.py
expected: PASS
condition: "only if inline-buttons variant chosen"
acceptance: [AC-1, AC-2]
- id: TC-12
type: unit
description: "(Условный) Обратная совместимость send_telegram: вызовы без reply_markup работают как раньше (payload без поля reply_markup)"
module: tests/test_telegram_tracker.py
expected: PASS
condition: "only if inline-buttons variant chosen"
acceptance: [AC-5]

View File

@@ -0,0 +1,117 @@
# ADR-001: Прямые ссылки в Telegram-уведомлении об апруве BRD (формат и Plane-URL)
Work Item: **ORCH-017** · Repo: `orchestrator` · Стадия: architecture
Тип: per-work-item ADR (НЕ сквозной — реестр гейтов/стадий/компонентов не меняется).
## Статус
Accepted
## Контекст
BRD (`01-brd.md`) и ТЗ (`02-trz.md`) требуют добавить в пингующее уведомление об апруве
BRD (`notify_approve_requested(task_id)` в `src/notifications.py`) две кликабельные ссылки:
на документ `01-brd.md` в Gitea и на Plane-issue. ТЗ намеренно оставило за стадией
architecture три развилки (открытые вопросы `01-brd.md` §8):
1. **§8.1 — формат ссылок:** HTML-`<a>` в тексте (минимум) **vs** inline-кнопки
(`reply_markup` в `send_telegram`).
2. **§8.4 — формат Plane-URL:** полный путь `.../projects/{project_id}/issues/{issue_id}/`
**vs** короткий `.../browse/<IDENT>/`.
3. **§8.3 — внешний web-URL Plane:** в конфиге есть только внутренний `plane_api_url`
(`http://localhost:8091`), непригодный для браузерной ссылки.
Жёсткое ограничение контекста — **self-hosting**: правка живёт в инструменте, который сейчас
обслуживает другие проекты из общего прод-контейнера. Любое расширение blast radius
(особенно правка разделяемой функции `send_telegram`, которой пользуется и живой трекер
PR #21/#22) — групповой риск. Поэтому из равноценных вариантов выбирается тот, что меняет
меньше кода и не трогает общие точки.
Фактическое состояние кода, проверенное на ветке:
- `send_telegram(text, disable_notification=False)` (`src/notifications.py:42`) шлёт
`parse_mode="HTML"` — HTML-`<a>` работает без изменения сигнатуры.
- Эталон branch-view ссылки на доки — `src/usage.py:455-458`:
`base = (gitea_public_url or gitea_url).rstrip('/')`, `owner = gitea_owner`,
URL `{base}/{owner}/{repo}/src/branch/{branch}/<rel>`.
- Plane-issue uuid надёжно лежит в `tasks.plane_issue_id`; `project_id` берётся через
`projects.get_project_by_repo(repo).plane_project_id`.
- В `plane_sync.py` строки `.../workspaces/{slug}/projects/{pid}/issues/{id}/` — это **API**
путь (`{plane_api_url}/api/v1/...`), НЕ браузерный. Браузерный роут Plane —
`{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}` (без `/api/v1`,
без сегмента `/workspaces/`).
## Решение
### Р-1 (§8.1) — HTML-ссылки в тексте. Inline-кнопки отклонены.
Ссылки встраиваются как `<a href="…">подпись</a>` в текст того же одного сообщения.
**`send_telegram` НЕ трогаем** (сигнатура без `reply_markup`). Inline-кнопки потребовали бы
правки разделяемой функции, которой пользуется живой трекер, — это рост blast radius без
бизнес-выгоды для одной точки уведомления. Расширение до кнопок — **вне объёма ORCH-017**;
при реальной потребности заводится отдельный work item.
### Р-2 (§8.4) — полный путь Plane-issue по uuid. Короткий `browse/<IDENT>` отклонён.
Формат:
```
{plane_web_base}/{workspace_slug}/projects/{project_id}/issues/{plane_issue_id}/
```
Источники: `plane_web_base` (Р-3), `workspace_slug = settings.plane_workspace_slug`,
`project_id = get_project_by_repo(repo).plane_project_id`, `plane_issue_id = tasks.plane_issue_id`.
Короткий `browse/<IDENT>` отклонён: он опирается на совпадение `work_item_id` с Plane-identifier,
которое не гарантировано из-за zero-padding (`ORCH-017` в БД vs `ORCH-17` как identifier).
uuid в `plane_issue_id` — детерминированный и уже в наличии источник.
### Р-3 (§8.3) — новая настройка `ORCH_PLANE_WEB_URL` + loopback-guard.
В `src/config.py` добавляется `plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`).
База резолвится как:
```python
plane_web_base = (settings.plane_web_url or settings.plane_api_url).rstrip("/")
```
**Loopback-guard (разрешение конфликта AC-2 ↔ AC-3):** дефолт-фоллбэк `plane_api_url` равен
`http://localhost:8091` и снаружи хоста не кликается. Поэтому: если итоговый `plane_web_base`
указывает на loopback/локальный хост (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) **или**
пуст — **Plane-ссылка опускается целиком** (а не вставляется битой). Так одновременно:
AC-2 (не выпускаем localhost-ссылку), AC-3 (цепочка фоллбэка соблюдена как попытка),
AC-6/NFR-1 (никаких исключений, сообщение уходит без отсутствующей ссылки).
### Р-4 — graceful degradation как контракт построения ссылок.
Чтение `repo/branch/plane_issue_id` из `tasks` — один SELECT в `try/except`. Каждая из двух
ссылок строится независимо; при нехватке данных конкретная ссылка опускается, призыв
«Переведите задачу в статус Approved …» и само сообщение сохраняются всегда. Динамические
подписи — через `html.escape`; URL формируются только из доверенных конфиг/БД-значений.
### Р-5 — инвариант «одно сообщение, без дублей».
Порядок действий в `notify_approve_requested` сохраняется: `mark_brd_review_started`
`update_task_tracker(task_id)` → один `send_telegram(msg)` (пингующий, не silent). Живой
трекер не дублируется. Реестр `QG_CHECKS`, стадии, `:approved:`-handler,
`check_analysis_approved` — без изменений (правка — отображение, не управление конвейером).
## Затронутые модули (для стадии development)
| Модуль | Изменение |
|--------|-----------|
| `src/notifications.py` | `notify_approve_requested`: SELECT `repo/branch/plane_issue_id`; сборка двух ссылок (Р-2/Р-3/Р-4); встраивание в текст. |
| `src/config.py` | `Settings.plane_web_url: str = ""` (env `ORCH_PLANE_WEB_URL`). |
| `src/projects.py` | (чтение) `get_project_by_repo(repo).plane_project_id`. |
| `src/usage.py` | (референс, НЕ править) паттерн branch-view URL. |
| `.env.example`, `CHANGELOG.md`, env-карта (`CLAUDE.md`/`INFRA.md`) | документация в том же PR. |
Без изменений API и схемы БД. Все требуемые поля уже есть в `tasks`.
## Последствия
**Плюсы:**
- Минимальный blast radius: разделяемая `send_telegram` не тронута → нулевой риск для живого
трекера и прочих уведомлений; безопасно для self-hosting.
- Детерминированная Plane-ссылка (uuid), не зависит от zero-padding identifier.
- Loopback-guard снимает противоречие AC-2/AC-3 и исключает «битые localhost-ссылки» в проде.
- Деплой штатный: не требует рестарта прод-контейнера сверх обычного деплоя; деплой ORCH
идёт через обязательный `deploy-staging` (8501).
**Минусы / ограничения:**
- Нет inline-кнопок (по дизайну отклонено) — UX чуть менее «кнопочный»; при необходимости
отдельный work item.
- Plane-ссылка появится только после задания `ORCH_PLANE_WEB_URL` на хосте (`.env`/`.env.staging`)
— см. `07-infra-requirements.md`. До этого момента graceful degradation: уведомление уходит
только с BRD-ссылкой.
- Корректность браузерного роута Plane (`/{workspace}/projects/{id}/issues/{id}/`) зависит от
версии Plane; риск зафиксирован в `10-tech-risks.md`.
## Открытые вопросы, переданные дальше
- **Значение `ORCH_PLANE_WEB_URL`** подтверждает Owner/INFRA при деплое (см. `07-infra-requirements.md`).
Это конфиг-параметр, а не блокер архитектуры.

View File

@@ -0,0 +1,38 @@
# 07-Infra Requirements — ORCH-017
Work Item: **ORCH-017** · Repo: `orchestrator`
Опирается на ADR-001 (Р-3). Меняется только env-карта; топология контейнеров/портов — без изменений.
## 1. Новая env-переменная
| Ключ | env | Дефолт | Назначение |
|------|-----|--------|------------|
| `plane_web_url` | `ORCH_PLANE_WEB_URL` | `""` (пусто) | Внешний **браузерный** базовый URL Plane для кликабельной ссылки на issue из Telegram. НЕ путать с внутренним `ORCH_PLANE_API_URL` (`http://localhost:8091`), который пригоден только для API. |
### Семантика резолва (ADR-001 Р-3)
```
plane_web_base = (ORCH_PLANE_WEB_URL or ORCH_PLANE_API_URL).rstrip("/")
```
- Если `plane_web_base` пуст **или** указывает на loopback (`localhost`, `127.0.0.1`,
`0.0.0.0`, `[::1]`) — Plane-ссылка **опускается** (graceful degradation, NFR-1). Без
заданного `ORCH_PLANE_WEB_URL` уведомление уходит только с BRD-ссылкой — это нормально.
## 2. Что требуется от Owner / INFRA
1. **Подтвердить значение `ORCH_PLANE_WEB_URL`** — внешний адрес Plane UI (тот, по которому
Слава открывает Plane в браузере). Это единственный внешний вход, требующий решения Owner.
2. Прописать ключ в `.env` (prod-хост) и `.env.staging` (staging-песочница). В git значение
НЕ коммитится — канон секретов/настроек (`.env.example` — образец без значения).
3. Браузерный роут issue, который будет собран:
`{ORCH_PLANE_WEB_URL}/{ORCH_PLANE_WORKSPACE_SLUG}/projects/{plane_project_id}/issues/{plane_issue_id}/`.
Проверить на одной задаче, что он открывается в текущей версии Plane (см. риск R-3 в
`10-tech-risks.md`).
## 3. Переиспользуемые (без изменений) настройки
- `ORCH_GITEA_PUBLIC_URL` / `ORCH_GITEA_URL`, `ORCH_GITEA_OWNER` — для BRD-ссылки.
- `ORCH_PLANE_WORKSPACE_SLUG` — workspace в Plane-URL.
## 4. Топология / деплой
- Контейнеры, порты, сети — **без изменений**. Новый ключ читается из `.env` при старте
(`pydantic Settings`, `env_prefix=ORCH_`).
- Деплой self (ORCH) — штатный, через обязательный `deploy-staging` (8501) перед прод-деплоем
(`orchestrator`, 8500). Рестарт прода сверх обычного деплоя НЕ требуется.
- Документировать ключ в env-карте: `CLAUDE.md` и/или `docs/operations/INFRA.md` (в том же PR).

View File

@@ -0,0 +1,19 @@
# 10-Tech Risks — ORCH-017
Work Item: **ORCH-017** · Repo: `orchestrator`
Опирается на ADR-001. Шкала: вероятность × влияние.
| ID | Риск | Вер. | Влияние | Митигация |
|----|------|------|---------|-----------|
| R-1 | **Self-hosting: уведомление роняет поток.** Исключение при построении ссылок (нет данных в `tasks`, неконсистентный реестр проектов) прерывает `notify_approve_requested` и тормозит конвейер всех проектов. | Низк. | Выс. | NFR-1/ADR Р-4: один SELECT в `try/except`, каждая ссылка строится независимо и опускается при нехватке данных; сообщение и призыв отправляются всегда. Тест на ветви degradation (`tests/test_notify_approve_links.py`). |
| R-2 | **Битый/непубличный Plane-URL.** Фоллбэк на `plane_api_url=localhost:8091` дал бы некликабельную ссылку снаружи хоста (нарушение AC-2). | Сред. | Сред. | ADR Р-3 loopback-guard: при пустом/loopback базовом URL Plane-ссылка опускается, а не вставляется битой. Значение `ORCH_PLANE_WEB_URL` подтверждает Owner/INFRA (`07-infra-requirements.md`). |
| R-3 | **Несовпадение браузерного роута Plane.** Формат `/{workspace}/projects/{id}/issues/{id}/` зависит от версии Plane; иной роут → ссылка ведёт в никуда (открывается, но не на ту issue). | Низк. | Сред. | Проверить роут на одной реальной задаче после задания `ORCH_PLANE_WEB_URL` (acceptance в staging). uuid `plane_issue_id` детерминирован — ошибка может быть только в шаблоне пути, не в идентификаторе. |
| R-4 | **Поломка HTML-разметки сообщения.** Неэкранированная динамическая подпись (напр. символы `<`/`&` в `work_item_id`/title) ломает `parse_mode="HTML"` → Telegram отвергает сообщение. | Низк. | Сред. | NFR-3/ADR Р-4: `html.escape` на всех подписях; URL только из доверенных конфиг/БД-значений. Тест на спецсимволы. |
| R-5 | **Регрессия «дубль-сообщения».** Случайное добавление второго `send_telegram` или повторная отправка трекера как нового сообщения. | Низк. | Низк. | ADR Р-5: инвариант «один `send_telegram`», порядок действий зафиксирован; регресс-тесты `tests/test_telegram_tracker.py`, `tests/test_notify_done_regression.py`. |
| R-6 | **Zero-padding identifier.** Короткий `browse/<IDENT>` промахнулся бы по issue (`ORCH-017` vs `ORCH-17`). | — | — | Снят на корню: ADR Р-2 использует uuid `plane_issue_id`, короткий формат отклонён. |
## Сводно
Изменение косметическое и изолированное: нет правок реестра гейтов/стадий, схемы БД, API и
разделяемой `send_telegram`. Главный класс риска — self-hosting-устойчивость (R-1) — закрыт
graceful-degradation контрактом ADR Р-4. Внешний незакрытый вход — значение `ORCH_PLANE_WEB_URL`
(R-2/R-3), проверяется в staging до прод-деплоя.

View File

@@ -0,0 +1,83 @@
---
type: review
work_item_id: ORCH-017
verdict: REQUEST_CHANGES
version: 4
---
# Review ORCH-017
## Summary
Основная фича (прямые BRD-/Plane-ссылки в `notify_approve_requested`) реализована
качественно и соответствует ТЗ, ADR-001 и всем критериям приёмки (подтверждено в
review v2: изменения по фиче — только `src/config.py` и `src/notifications.py`).
P0 из review v3 (правка разделяемого гейта `check_tests_passed` коммитом `e62d51a`,
нарушавшая ADR-001 Р-5 и ТЗ §7) **снят**: коммит `d615747` откатил изменение
`src/qg/checks.py` (вынесено в отдельный work item ORCH-47 со своим ADR). Код гейта
теперь идентичен `main` (читает только `verdict:`/`status:`); ADR-001 Р-5 и ТЗ §7
снова консистентны с кодом. ✔
Однако откат кода **не сопровождён откатом документации**: `CHANGELOG.md` и
`docs/architecture/README.md` всё ещё описывают откаченную правку гейта и ссылаются
на не существующие в этом PR тесты `tests/test_qg.py`. Это новый doc↔code конфликт
(golden source). → REQUEST_CHANGES (P1).
## Соответствие ТЗ
- §3.1§3.2, §4§6 (фича уведомления) — выполнено. `_build_brd_link` /
`_build_plane_issue_link` строят ссылки независимо, встроены в текст одного
сообщения; призыв «Переведите задачу в статус Approved …» сохранён;
`html.escape` на динамике; порядок `mark_brd_review_started → update_task_tracker
→ send_telegram(msg)` соблюдён; `Settings.plane_web_url` + фолбэк добавлены. ✔
- §7 — соблюдено. Реестр `QG_CHECKS`, стадии и машинные вердикты в коде не меняются
(правка гейта откачена в `d615747`). ✔
## Соответствие ADR
- ADR-001 (Р-1…Р-5) — соблюдён. Ссылки HTML-`<a>` в тексте, `send_telegram` не
тронута; полный Plane-URL по uuid; `ORCH_PLANE_WEB_URL` + loopback-guard
(`_is_loopback_base`); graceful degradation; «одно сообщение, без дублей». ✔
- ADR-001 Р-5 vs код — конфликт снят откатом гейта. ✔
## Качество кода
Фича `notifications.py`/`config.py` — без замечаний. Чтение полей задачи
(`_get_task_link_fields`) и обе сборки ссылок защищены try/except и никогда не
роняют alert (AC-6); loopback-guard корректно опускает неклика­бельный Plane-URL
(AC-2/AC-3); `html.escape(..., quote=True)` на href и `html.escape(work_item_id)`
на подписи (AC-7). Тесты `tests/test_notify_approve_links.py`,
`tests/test_analysis_approve_flow_links.py` присутствуют и содержательны.
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- [ ] **Документация описывает откаченный код (doc↔code конфликт).** После
revert-коммита `d615747` код `src/qg/checks.py` НЕ читает `result:` (только
`verdict:`/`status:`), но документация осталась от состояния `e62d51a`:
- `docs/architecture/README.md:61` утверждает, что `check_tests_passed`
читает `verdict:`/`status:`/`result:` — это ложно для текущего кода и
вводит в заблуждение по поведению разделяемого прод-гейта (self-hosting:
tester, написавший только `result: PASS`, реально провалит гейт).
- `CHANGELOG.md:24` (секция Fixed) содержит запись о правке гейта
`check_tests_passed` под тегом ORCH-017 и ссылается на отсутствующие в PR
тесты `tests/test_qg.py::TestCheckTestsPassed::test_result_pass_only_passes`
/ `…::test_result_fail_only_fails`.
**Резолюция:** убрать из ORCH-017 PR обе записи (откатить README:61 к
формулировке `main` и удалить CHANGELOG-entry про гейт) — правка гейта
принадлежит ORCH-47 и должна документироваться там вместе с её кодом.
### P2 — Should fix
- [ ] `13-test-report.md` (`result: PASS`) относится к прогону, включавшему
откаченную правку гейта; после устранения P1 канонический ре-тест — на
стадии testing (отчёт не должен ссылаться на снятые из PR изменения).
## Документация
Правило «изменён `src/` → обновлена документация в том же PR» по фиче уведомления —
выполнено: `CHANGELOG.md` (Added), `.env.example` (`ORCH_PLANE_WEB_URL`),
`docs/operations/INFRA.md` (env-карта), ADR-001. ✔
Неконсистентность (P1): документация про откаченную правку гейта `check_tests_passed`
осталась в `CHANGELOG.md` (Fixed) и `docs/architecture/README.md`, хотя
соответствующий код отозван (`d615747`) и перенесён в ORCH-47. Доку нужно привести в
соответствие с кодом этого PR.

View File

@@ -0,0 +1,91 @@
---
type: test-report
work_item_id: ORCH-017
result: PASS
---
# Test Report — ORCH-017
Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве BRD.
Вердикт review (`12-review.md`): **APPROVED** ✔ — прогон регресса допущен.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (pytest-asyncio 0.23.8, anyio 4.13.0)
- Дата: 2026-06-05
- Ветка: `feature/ORCH-017-brd-plane-telegram`
- Прод-контейнер `orchestrator` (8500) НЕ перезапускался; smoke — только read-only GET.
## Smoke test API (prod, read-only)
| Endpoint | HTTP | Результат |
|----------|------|-----------|
| `GET /health` | 200 | `{"status":"ok","service":"orchestrator"}` — PASS |
| `GET /status` | 200 | active_tasks содержит task #35 ORCH-017 (stage=testing) — PASS |
| `GET /queue` | 200 | counts running=1, failed=0, breaker=closed, preflight ok — PASS |
> `curl` в окружении отсутствует — smoke выполнен через `urllib.request` (GET, без побочных эффектов).
## Результаты по test-plan (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | BRD-ссылка на `01-brd.md` (Gitea branch-view) | `test_notify_approve_links::test_tc01_brd_link_present` | PASS |
| TC-02 | Plane-ссылка (web-URL+workspace+project+issue_id) | `…::test_tc02_plane_link_present` | PASS |
| TC-03 | Фоллбэки URL (gitea_public_url→gitea_url, plane_web_url→plane_api_url) | `…::test_tc03_url_fallbacks` | PASS |
| TC-04 | Сохранён призыв «Approved» | `…::test_tc04_keeps_approved_call_to_action` | PASS |
| TC-05 | Ровно одно пингующее сообщение (не silent) | `…::test_tc05_single_notifying_message` | PASS |
| TC-06 | Graceful: branch/issue=None — без исключения | `…::test_tc06_graceful_missing_branch_and_issue` | PASS |
| TC-07 | Пустой Plane-base → Plane-ссылка опущена, BRD остаётся | `…::test_tc07_plane_base_empty_drops_plane_link_keeps_brd` | PASS |
| TC-07b | Loopback Plane-base отбрасывается (доп.) | `…::test_tc07b_loopback_plane_base_dropped` | PASS |
| TC-08 | parse_mode=HTML, html.escape, валидная разметка | `…::test_tc08_html_escaped_and_valid_markup` | PASS |
| TC-08b | send_telegram сохраняет parse_mode=HTML (доп.) | `…::test_tc08b_send_telegram_keeps_parse_mode_html` | PASS |
| TC-09 | Регрессия трекера (silent edit, без дублей) | `test_telegram_tracker.py` (полный набор) | PASS |
| TC-10 | Поток analysis-approved строит ссылки из БД | `test_analysis_approve_flow_links::test_tc10_approved_flow_builds_links_from_db` | PASS |
| TC-11 | (Условный) inline-кнопки | — | N/A — вариант кнопок отклонён (ADR-001 Р-1) |
| TC-12 | (Условный) обратная совместимость send_telegram c reply_markup | — | N/A — вариант кнопок отклонён (ADR-001 Р-1) |
Все запланированные тесты (TC-01…TC-10) — PASS. Условные TC-11/TC-12 не применимы:
ADR-001 (Р-1) зафиксировал HTML-ссылки в тексте без изменения сигнатуры `send_telegram`.
## Покрытие критериев приёмки (03-acceptance-criteria.md)
| AC | Покрывающие TC | Статус |
|----|----------------|--------|
| AC-1 | TC-01, TC-10 | PASS |
| AC-2 | TC-02, TC-10 | PASS |
| AC-3 | TC-01, TC-02, TC-03 | PASS |
| AC-4 | TC-04 | PASS |
| AC-5 | TC-05, TC-09 | PASS |
| AC-6 | TC-06, TC-07, TC-07b | PASS |
| AC-7 | TC-08, TC-08b | PASS |
| AC-8 | TC-09, TC-10 | PASS |
| AC-9 | проверено review (CHANGELOG/.env.example/INFRA.md/ADR) | PASS |
| AC-10 | полный регресс `pytest tests/` | PASS |
## Вывод pytest
### Целевые тесты ORCH-017
```
tests/test_notify_approve_links.py::test_tc01_brd_link_present PASSED
tests/test_notify_approve_links.py::test_tc02_plane_link_present PASSED
tests/test_notify_approve_links.py::test_tc03_url_fallbacks PASSED
tests/test_notify_approve_links.py::test_tc04_keeps_approved_call_to_action PASSED
tests/test_notify_approve_links.py::test_tc05_single_notifying_message PASSED
tests/test_notify_approve_links.py::test_tc06_graceful_missing_branch_and_issue PASSED
tests/test_notify_approve_links.py::test_tc07_plane_base_empty_drops_plane_link_keeps_brd PASSED
tests/test_notify_approve_links.py::test_tc07b_loopback_plane_base_dropped PASSED
tests/test_notify_approve_links.py::test_tc08_html_escaped_and_valid_markup PASSED
tests/test_notify_approve_links.py::test_tc08b_send_telegram_keeps_parse_mode_html PASSED
tests/test_analysis_approve_flow_links.py::test_tc10_approved_flow_builds_links_from_db PASSED
11 passed in 0.53s
```
### Полный регресс
```
======================== 434 passed, 1 warning in 7.99s ========================
```
Единственное предупреждение — PydanticDeprecatedSince20 (`src/config.py:4`, class-based config),
предсуществующее, к ORCH-017 не относится, на результат не влияет.
## Итог
**PASS** — 434/434 теста зелёные, целевые TC-01…TC-10 пройдены, все 10 критериев приёмки
покрыты, smoke API прод-инстанса OK. Задача готова к стадии **deploy-staging**.

View File

@@ -0,0 +1,7 @@
# Business Request: stage_engine: pass reviewer/tester findings text to developer (not just link)
Work Item ID: ORCH-046
## Description
TBD

View File

@@ -0,0 +1,86 @@
# BRD — ORCH-046: pass reviewer/tester findings text to developer (not just link)
Work Item ID: ORCH-046
Stage: analysis
Author: analyst
Date: 2026-06-06
## 1. Контекст и проблема
Оркестратор при заворотах задачи деву (откат на `development`) формирует
описание задачи (`task_desc`), которое попадает в `.task-dev.md` запускаемого
агента-разработчика. Сейчас в двух ветках отката этот текст содержит **только
ссылку на файл-артефакт**, без сути замечаний:
- **Reviewer → REQUEST_CHANGES** (`src/stage_engine.py`, ветка
`_handle_qg_failure_rollbacks`, ~стр. 419): `task_desc` =
`"…Fix findings in docs/work-items/<id>/12-review.md"`.
- **Tester → FAIL** (`check_tests_passed`, ~стр. 455): `task_desc` =
`"…Fix failures described in docs/work-items/<id>/13-test-report.md"`.
В результате developer-агент получает инструкцию «иди читай файл». Ключевые
претензии (P0/P1 у ревьюера, причина падения у тестера) часто проскакивают —
агент не открывает файл целиком или теряет фокус, повторяет ту же ошибку, и
задача снова заворачивается. Это «испорченный телефон»: расход циклов retry
(`MAX_DEVELOPER_RETRIES = 3`), деньги на токены, простой конвейера.
## 2. Бизнес-цель
Убрать «испорченный телефон» между reviewer/tester и developer при заворотах:
встраивать **дословный текст ключевых замечаний** прямо в `task_desc`, чтобы
developer-агент видел суть претензий сразу, а не только ссылку.
Это снижает число повторных заворотов и расход retry-бюджета на одну задачу.
## 3. Объём (вариант A — выбран Славой 06.06)
Минимальное, низкорисковое изменение **ядра** (`stage_engine`), которое:
1. Извлекает из `12-review.md` блок findings и выносит **must-fix (P0/P1)
дословно** в `task_desc` при reviewer REQUEST_CHANGES.
2. Извлекает из `13-test-report.md` причину FAIL (reason из гейта + релевантный
фрагмент тела отчёта) в `task_desc` при tester FAIL.
3. Во всех случаях **сохраняет ссылку на полный файл** как дополнительный
контекст («полный контекст — см. файл»).
4. Извлечение выполняется новым отдельным хелпером-парсером
(`src/review_parse.py`), который **никогда не бросает исключение**: при
отсутствующем/битом файле возвращает пустой результат, и вызывающий код
делает graceful fallback на прежнюю ссылку-строку.
## 4. Что НЕ входит в объём (out of scope)
- НЕ трогать гейты `check_*` (в т. ч. ORCH-45 `check_ci_green`,
ORCH-47 `_parse_tests_verdict`) — они в проде, поведение неизменно.
- НЕ трогать реестр `QG_CHECKS`.
- НЕ менять сигнатуры публичных функций (`advance_stage`, `_run_qg`,
`check_*`).
- НЕ менять webhook-пути.
- НЕ менять retry-счётчик (`_developer_retry_count`, `MAX_DEVELOPER_RETRIES`)
и rollback-логику (последовательность `update_task_stage`
`notify_stage_change``plane_notify_stage` → enqueue) — поведение
идентично.
- НЕ менять формат Plane-комментариев (`build_status_comment`).
## 5. Заинтересованные стороны
- **Owner (Слава)** — заказчик, выбрал вариант A.
- **Developer-агенты** — потребители `task_desc`: получают суть замечаний.
- **Конвейер всех проектов** (self-hosting) — выигрывает за счёт меньшего
числа заворотов.
## 6. Ограничения и риски (self-hosting)
- Правка ядра `stage_engine` — компонент крутится в продакшене и обслуживает
все проекты из общего инстанса/БД/очереди. Любая регрессия в формировании
`task_desc` или (тем более) исключение в `advance_stage` останавливает
конвейер всех проектов → **парсер обязан быть полностью graceful**.
- Обязателен прогон `deploy-staging` (8501) перед прод-деплоем.
- Это правка ядра → требуется ADR (per-work-item).
## 7. Критерий успеха (бизнес)
- При заворотах в `.task-dev.md` есть дословный текст ключевых замечаний
(P0/P1 ревьюера; reason+фрагмент тестера) плюс ссылка на полный файл.
- Парсер устойчив к битым/отсутствующим артефактам (graceful fallback на
старую ссылку-строку).
- Существующие тесты зелёные; поведение retry/rollback не изменилось.

View File

@@ -0,0 +1,209 @@
# ТЗ — ORCH-046: встраивание текста findings reviewer/tester в task_desc
Work Item ID: ORCH-046
Stage: analysis
Author: analyst
Date: 2026-06-06
> Вариант A (минимальный, низкий риск). Это правка ЯДРА — обязателен ADR
> (per-work-item, `docs/work-items/ORCH-046/06-adr/`).
## 1. Задействованные модули `src/`
| Модуль | Изменение |
|--------|-----------|
| `src/review_parse.py` | **НОВЫЙ** хелпер-парсер: `extract_review_findings(path) -> str`, `extract_test_failures(path) -> str`. |
| `src/stage_engine.py` | Две ветки в `_handle_qg_failure_rollbacks`: reviewer REQUEST_CHANGES (~стр. 419) и tester `check_tests_passed` FAIL (~стр. 455) — встраивают извлечённый текст в `task_desc`. |
Источники-образцы (не менять, использовать как референс паттерна «never raise» и
формата артефактов):
- `src/qg/checks.py::_parse_tests_verdict` — образец «never raise», split по `---`, `yaml.safe_load`.
- `src/frontmatter.py::read_frontmatter_value` — образец defensive-парсера.
- `.openclaw/agents/reviewer.md` — канонический формат `12-review.md`.
- `.openclaw/agents/tester.md` — канонический формат `13-test-report.md`.
## 2. Новый модуль `src/review_parse.py`
### 2.1. `extract_review_findings(path: str) -> str`
Назначение: вернуть **дословный** текст must-fix findings (P0 + P1) из
`12-review.md` для встраивания в `task_desc`.
Формат входного файла (канон reviewer.md, секция `## Findings`):
```markdown
## Findings
### P0 — Blocker
- [ ] <описание>
### P1 — Must fix
- [ ] <описание>
### P2 — Should fix
- [ ] <описание>
```
Требования к реализации:
1. **Никогда не бросает исключение.** Любая ошибка (нет файла, IOError, кривой
markdown, нет секции `## Findings`) → возврат `""` (пустая строка).
2. Парсит **только** подсекции P0 и P1 (must-fix). P2/P3 игнорируются.
3. Заголовки подсекций распознаются устойчиво к регистру и к тире/дефису:
соответствие по наличию токена `P0` / `P1` в строке-заголовке уровня `###`.
4. Из распознанных подсекций берётся текст до следующего заголовка `###`/`##`
(т. е. тело подсекции дословно: пункты списка `- [ ] …` / `- …`).
5. Пустые подсекции (нет содержательных пунктов, только `(если есть)`-плейсхолдер
или ничего) — пропускаются. Если ни одного содержательного P0/P1 пункта нет
→ возврат `""`.
6. Результат — компактный многострочный текст, пригодный для вставки в
`task_desc` (например, заголовок подсекции + её пункты). Длина результата
ограничивается разумным лимитом (`MAX_FINDINGS_CHARS`, напр. 2000) с
усечением и маркером `…(truncated)`; полный контекст всё равно остаётся в
файле.
7. Frontmatter (верхний `--- … ---`) при необходимости отбрасывается, чтобы не
попасть в тело; парсинг секции делается по телу markdown.
Сигнатура и контракт (стабильны):
```python
def extract_review_findings(path: str) -> str:
"""Дословный текст P0/P1 findings из 12-review.md. Never raises; '' при ошибке/пусто."""
```
### 2.2. `extract_test_failures(path: str) -> str`
Назначение: вернуть текст причины падения тестов из `13-test-report.md` для
встраивания в `task_desc`.
Формат входного файла (канон tester.md): frontmatter `result: PASS|FAIL`, далее
тело с секциями `## Результаты` (таблица TC), `## Вывод pytest`, `## Итог`.
Требования к реализации:
1. **Никогда не бросает исключение.** Любая ошибка → возврат `""`.
2. Извлекает релевантный фрагмент тела, помогающий понять причину FAIL.
Приоритет источников (берём первый непустой):
- секция `## Вывод pytest` (вывод прогона — где видно упавшие тесты), и/или
- строки таблицы `## Результаты`, содержащие `FAIL`, и/или
- секция `## Итог`.
3. Результат усекается до `MAX_FAILURES_CHARS` (напр. 2000) с маркером
`…(truncated)`.
4. Если ничего извлечь не удалось → возврат `""` (вызывающий код делает
fallback на ссылку).
> Примечание: «reason» из самого гейта (`check_tests_passed` → второй элемент
> кортежа) у вызывающего кода уже есть (`reason`) — он добавляется в `task_desc`
> вызывающим кодом (как и сейчас в комментарии тестера). `extract_test_failures`
> добавляет **фрагмент тела отчёта** поверх этого reason.
Сигнатура и контракт (стабильны):
```python
def extract_test_failures(path: str) -> str:
"""Релевантный фрагмент тела 13-test-report.md (причина FAIL). Never raises; '' при ошибке/пусто."""
```
### 2.3. Общие требования модуля
- Модуль логирует диагностические сообщения на уровне `logger.debug`
(`logging.getLogger("orchestrator.review_parse")`), как `frontmatter.py`.
- Никаких сетевых вызовов, только чтение файла с диска.
- Константы лимитов вынесены модульными (`MAX_FINDINGS_CHARS`,
`MAX_FAILURES_CHARS`).
## 3. Изменения `src/stage_engine.py`
### 3.1. Ветка reviewer REQUEST_CHANGES (внутри `_handle_qg_failure_rollbacks`)
Текущее (~стр. 418424):
```python
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: development\nNote: REQUEST_CHANGES from reviewer "
f"(attempt {retry_count+1}/3). Fix findings in "
f"docs/work-items/{work_item_id}/12-review.md"
)
```
Целевое поведение:
- Сформировать путь к `12-review.md` через `get_worktree_path(repo, branch)` +
`docs/work-items/{work_item_id}/12-review.md` (как в `_check_review_approved_by_branch`).
- Вызвать `extract_review_findings(path)`.
- Если результат непустой — встроить findings **дословно** в `task_desc`
(под подзаголовком, напр. `Findings (P0/P1):\n<text>`), а ссылку на файл
оставить как «полный контекст» (`Полный контекст: docs/work-items/<id>/12-review.md`).
- Если результат пустой (graceful fallback) — `task_desc` остаётся **как
сейчас** (ссылка-строка). Никаких исключений.
- Префиксная часть (`Work item / Repo / Branch / Stage / Note: REQUEST_CHANGES …
(attempt N/3)`) сохраняется без изменений.
### 3.2. Ветка tester FAIL (`check_tests_passed`, внутри `_handle_qg_failure_rollbacks`)
Текущее (~стр. 454459):
```python
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: development\nNote: Tests FAILED. "
f"Fix failures described in docs/work-items/{work_item_id}/13-test-report.md"
)
```
Целевое поведение:
- Сформировать путь к `13-test-report.md` аналогично.
- Вызвать `extract_test_failures(path)`.
- В `task_desc` всегда включить `reason` (он уже доступен в этой ветке —
передаётся в `_handle_qg_failure_rollbacks`).
- Если фрагмент тела непустой — встроить его дословно
(`Причина: {reason}\nДетали:\n<fragment>`), плюс ссылку на файл как полный
контекст.
- Если фрагмент пустой — `task_desc` содержит `reason` + ссылку (graceful
fallback, не хуже текущего поведения). Никаких исключений.
- Префиксная часть и существующий Plane-комментарий тестера
(`❌ Тесты не прошли: {reason}…`) НЕ меняются.
### 3.3. Инварианты (НЕ менять поведение)
- Последовательность rollback в обеих ветках: `update_task_stage(task_id,
"development")` → `notify_stage_change` → `plane_notify_stage` →
(`set_issue_in_progress` для тестера) → проверка `_developer_retry_count` <
`MAX_DEVELOPER_RETRIES` → `enqueue_job("developer", …)` либо
`send_telegram` alert. Порядок и условия идентичны.
- `result.rolled_back_to`, `result.enqueued_agent`, `result.enqueued_job_id`,
`result.alerted` выставляются как сейчас.
- Меняется **только содержимое строки `task_desc`**, передаваемой в
`enqueue_job`.
- Импорт нового модуля — `from .review_parse import extract_review_findings,
extract_test_failures` в шапке `stage_engine.py`.
## 4. Изменения API
Нет. Публичные HTTP-эндпоинты (`/health`, `/status`, `/queue`,
`/webhook/plane`, `/webhook/gitea`) не затрагиваются.
## 5. Изменения схемы БД
Нет. Таблицы `tasks`, `agent_runs`, `jobs`, `events` не меняются.
`enqueue_job` вызывается с прежней сигнатурой.
## 6. Требования к новым QG checks
Нет. Реестр `QG_CHECKS` и все `check_*` не трогаются (явно out of scope).
## 7. Артефакты pipeline (создать/обновить в этом PR)
- `src/review_parse.py` — новый модуль.
- `tests/test_review_parse.py` — юнит-тесты парсера (см. 04-test-plan.yaml).
- Возможные дополнения в `tests/test_stage_engine.py` — проверка встраивания
текста в `task_desc` (rollback-ветки).
- `docs/work-items/ORCH-046/06-adr/ADR-001-*.md` — ADR (правка ядра).
- `docs/architecture/README.md` / `internals.md` — описание нового хелпера и
поведения заворотов (если reviewer сочтёт необходимым; компонент описать в
разделе Stage Engine / Откаты).
- `CHANGELOG.md` — запись о ORCH-046.
## 8. Контроль качества / проверка
```bash
python -m pytest tests/ -q # в контейнере; все тесты зелёные
```
Обязательно: стадия `deploy-staging` (8501) перед прод-деплоем (self-hosting).

View File

@@ -0,0 +1,99 @@
# Критерии приёмки — ORCH-046
Work Item ID: ORCH-046
Stage: analysis
Author: analyst
Date: 2026-06-06
Каждый критерий имеет чёткое условие PASS/FAIL. Reviewer/Tester проверяют по
этому списку.
## AC-1 — Дословные P0/P1 findings ревьюера в task_desc
**Условие:** при reviewer REQUEST_CHANGES (откат `review`/`testing`
`development`) строка `task_desc`, переданная в `enqueue_job("developer", …)`,
содержит ДОСЛОВНЫЙ текст findings уровня P0/P1 из `12-review.md` (не только
ссылку).
- **PASS:** в `task_desc` присутствуют дословные строки P0/P1 пунктов из секции
`## Findings` файла `12-review.md`.
- **FAIL:** `task_desc` содержит только ссылку на файл, без текста findings (при
наличии валидного файла с P0/P1).
## AC-2 — Причина падения тестера в task_desc
**Условие:** при tester FAIL (`check_tests_passed`, откат `testing`
`development`) строка `task_desc` содержит причину падения: `reason` из гейта +
релевантный фрагмент тела `13-test-report.md`.
- **PASS:** `task_desc` содержит `reason` И непустой фрагмент тела отчёта
(вывод pytest / FAIL-строки / Итог), когда отчёт валиден.
- **FAIL:** `task_desc` содержит только ссылку на файл без причины/фрагмента
(при наличии валидного отчёта).
## AC-3 — Ссылка на полный файл сохранена
**Условие:** в обеих ветках (reviewer, tester) `task_desc` по-прежнему содержит
ссылку на полный файл-артефакт (`docs/work-items/<id>/12-review.md` /
`13-test-report.md`) как дополнительный контекст.
- **PASS:** путь к файлу присутствует в `task_desc` в обоих сценариях.
- **FAIL:** ссылка на файл удалена/отсутствует.
## AC-4 — Парсер устойчив к отсутствию/битому файлу (graceful)
**Условие:** `extract_review_findings(path)` и `extract_test_failures(path)`
НИКОГДА не бросают исключение; при отсутствующем/нечитаемом/битом файле
возвращают `""`, а вызывающий код в `stage_engine` делает fallback на прежнюю
ссылку-строку.
- **PASS:** на несуществующем пути, пустом файле, файле без секций, битом
markdown/YAML — функции возвращают `""` без исключения; `advance_stage`
отрабатывает откат как раньше (ссылка-строка в `task_desc`).
- **FAIL:** любое исключение наружу из парсера или из `advance_stage` из-за
парсинга.
## AC-5 — Тесты зелёные + новые юнит-тесты парсера
**Условие:** существующие тесты не сломаны; добавлены юнит-тесты парсера,
покрывающие: findings есть / findings пусто / битый YAML(frontmatter) / только
P3 (нет P0/P1).
- **PASS:** `python -m pytest tests/ -q` зелёный; `tests/test_review_parse.py`
содержит как минимум кейсы: P0/P1 присутствуют → текст возвращён; нет
findings/только P2-P3 → `""`; битый файл → `""`; отсутствующий путь → `""`;
для test-report: FAIL-фрагмент извлечён / пустой отчёт → `""`.
- **FAIL:** падение существующих тестов или отсутствие перечисленных кейсов.
## AC-6 — Retry-счётчик и rollback НЕ изменены по поведению
**Условие:** логика `_developer_retry_count`, `MAX_DEVELOPER_RETRIES = 3`,
последовательность откатов и поля `AdvanceResult` (`rolled_back_to`,
`enqueued_agent`, `enqueued_job_id`, `alerted`) идентичны прежним.
- **PASS:** существующие тесты `test_stage_engine.py` на rollback/retry зелёные;
при 4-м заходе по-прежнему alert вместо enqueue; меняется только текст
`task_desc`.
- **FAIL:** изменилось число retry, порядок вызовов, или значения полей
`AdvanceResult`.
## AC-7 — Out-of-scope не затронут
**Условие:** не изменены: `check_*` гейты, реестр `QG_CHECKS`, сигнатуры
публичных функций (`advance_stage`, `_run_qg`, `check_*`), webhook-пути, формат
Plane-комментариев.
- **PASS:** `git diff` не содержит изменений в `src/qg/checks.py` (логика
гейтов), сигнатурах публичных функций, `src/webhooks/*`,
`usage.build_status_comment`; `test_qg_registry_snapshot` зелёный.
- **FAIL:** любое из перечисленного изменено.
## AC-8 — Документация и ADR обновлены (golden source)
**Условие:** правка ядра → заведён ADR (`06-adr/`), обновлён `CHANGELOG.md`, при
необходимости — `docs/architecture/README.md`/`internals.md` (раздел Stage
Engine / Откаты).
- **PASS:** присутствует `docs/work-items/ORCH-046/06-adr/ADR-001-*.md`; в
`CHANGELOG.md` есть запись ORCH-046.
- **FAIL:** ADR или запись в CHANGELOG отсутствуют.

View File

@@ -0,0 +1,108 @@
work_item: ORCH-046
description: >
Тест-план для встраивания дословного текста findings reviewer/tester в
task_desc при заворотах деву. Покрывает новый парсер src/review_parse.py
(graceful, never-raise) и две rollback-ветки src/stage_engine.py.
tests:
# --- Парсер review findings (extract_review_findings) -------------------
- id: TC-01
type: unit
description: "extract_review_findings возвращает дословный текст P0/P1 при их наличии в 12-review.md"
module: tests/test_review_parse.py
covers: [AC-1, AC-5]
expected: PASS
- id: TC-02
type: unit
description: "extract_review_findings возвращает '' когда есть только P2/P3 (нет must-fix P0/P1)"
module: tests/test_review_parse.py
covers: [AC-5]
expected: PASS
- id: TC-03
type: unit
description: "extract_review_findings возвращает '' для отсутствующего файла (несуществующий путь), без исключения"
module: tests/test_review_parse.py
covers: [AC-4]
expected: PASS
- id: TC-04
type: unit
description: "extract_review_findings возвращает '' для битого/пустого файла или markdown без секции ## Findings, без исключения"
module: tests/test_review_parse.py
covers: [AC-4, AC-5]
expected: PASS
- id: TC-05
type: unit
description: "extract_review_findings усекает очень длинные findings до лимита с маркером truncated"
module: tests/test_review_parse.py
covers: [AC-1]
expected: PASS
# --- Парсер test failures (extract_test_failures) ----------------------
- id: TC-06
type: unit
description: "extract_test_failures извлекает релевантный фрагмент тела (Вывод pytest / FAIL-строки / Итог) из 13-test-report.md с result: FAIL"
module: tests/test_review_parse.py
covers: [AC-2, AC-5]
expected: PASS
- id: TC-07
type: unit
description: "extract_test_failures возвращает '' для отсутствующего файла, без исключения"
module: tests/test_review_parse.py
covers: [AC-4]
expected: PASS
- id: TC-08
type: unit
description: "extract_test_failures возвращает '' для битого/пустого отчёта (нет тела/секций), без исключения"
module: tests/test_review_parse.py
covers: [AC-4, AC-5]
expected: PASS
# --- Интеграция со stage_engine (rollback task_desc) -------------------
- id: TC-09
type: integration
description: "advance_stage: reviewer REQUEST_CHANGES -> в enqueue_job('developer') task_desc содержит дословные P0/P1 findings И ссылку на 12-review.md"
module: tests/test_stage_engine.py
covers: [AC-1, AC-3]
expected: PASS
- id: TC-10
type: integration
description: "advance_stage: tester check_tests_passed FAIL -> task_desc содержит reason + фрагмент 13-test-report.md И ссылку на файл"
module: tests/test_stage_engine.py
covers: [AC-2, AC-3]
expected: PASS
- id: TC-11
type: integration
description: "advance_stage: reviewer REQUEST_CHANGES при отсутствующем/битом 12-review.md -> graceful fallback, task_desc = прежняя ссылка-строка, без исключения"
module: tests/test_stage_engine.py
covers: [AC-4, AC-3]
expected: PASS
- id: TC-12
type: integration
description: "advance_stage: rollback/retry поведение неизменно — последовательность откатов, _developer_retry_count, alert на 4-й заход, поля AdvanceResult"
module: tests/test_stage_engine.py
covers: [AC-6]
expected: PASS
# --- Регресс / неизменность out-of-scope ------------------------------
- id: TC-13
type: integration
description: "Реестр QG_CHECKS не изменён (snapshot), гейты check_* нетронуты"
module: tests/test_qg_registry_snapshot.py
covers: [AC-7]
expected: PASS
- id: TC-14
type: integration
description: "Полный регресс существующего набора зелёный: python -m pytest tests/ -q"
module: tests/
covers: [AC-5, AC-6, AC-7]
expected: PASS

View File

@@ -0,0 +1,143 @@
# ADR-001: дословный текст findings reviewer/tester встраивается в `task_desc` через отдельный graceful-парсер
- **Статус:** Accepted
- **Дата:** 2026-06-06
- **Задача:** ORCH-046
- **Область:** ЯДРО `src/stage_engine.py` (rollback-ветки) + новый модуль `src/review_parse.py`. Общий прод-инстанс (orchestrator + enduro-trails), self-hosting.
## Контекст
При заворотах задачи на `development` (откат) `stage_engine` формирует `task_desc`,
который попадает в `.task-dev.md` запускаемого developer-агента. В двух ветках
`_handle_qg_failure_rollbacks` этот текст содержит **только ссылку на файл-артефакт**:
- reviewer REQUEST_CHANGES (`src/stage_engine.py` ~стр. 419) → `…Fix findings in docs/work-items/<id>/12-review.md`;
- tester `check_tests_passed` FAIL (~стр. 455) → `…Fix failures described in docs/work-items/<id>/13-test-report.md`.
Developer-агент получает инструкцию «иди читай файл»; ключевые претензии (P0/P1
ревьюера, причина падения тестера) теряются — агент повторяет ту же ошибку, и
задача заворачивается снова. Это «испорченный телефон»: расход retry-бюджета
(`MAX_DEVELOPER_RETRIES = 3`), токенов и простой конвейера (для всех проектов
общего инстанса).
Ограничение из BRD/ТЗ (вариант A, выбран Owner): минимальная, низкорисковая
правка ядра. Любая регрессия в формировании `task_desc` или (тем более)
исключение в `advance_stage` останавливает конвейер ВСЕХ проектов — следовательно
извлечение текста обязано быть полностью graceful.
## Решение
Встраивать **дословный текст ключевых замечаний** в `task_desc` при заворотах,
сохраняя ссылку на полный файл как дополнительный контекст. Извлечение вынести в
отдельный defensive-модуль, чтобы изолировать blast radius от ядра.
1. **Новый модуль `src/review_parse.py`** с двумя чистыми функциями чтения с диска:
- `extract_review_findings(path: str) -> str` — дословные пункты P0/P1 из секции
`## Findings` файла `12-review.md`;
- `extract_test_failures(path: str) -> str` — релевантный фрагмент тела
`13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты`
`## Итог`).
- **Контракт «never raise»** (как `src/frontmatter.py` и
`src/qg/checks.py::_parse_tests_verdict`): любая ошибка — нет файла, IOError,
кривой markdown/YAML, нет секции — возвращает `""`. Логирование на
`logger.debug` (`logging.getLogger("orchestrator.review_parse")`). Никаких
сетевых вызовов.
- Результат усекается модульными лимитами `MAX_FINDINGS_CHARS`,
`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`; полный контекст всегда
остаётся в файле.
2. **Две ветки `_handle_qg_failure_rollbacks` в `src/stage_engine.py`** строят путь
через `get_worktree_path(repo, branch)` (как `_check_review_approved_by_branch`),
вызывают соответствующий парсер и:
- если результат непустой — встраивают findings/фрагмент **дословно** под
подзаголовком + оставляют ссылку как «полный контекст»;
- если результат пустой — `task_desc` остаётся **как сейчас** (graceful fallback
на ссылку-строку);
- tester-ветка дополнительно всегда включает `reason` из гейта (он уже доступен).
3. **Изоляция ядра.** Меняется ТОЛЬКО содержимое строки `task_desc`, передаваемой в
`enqueue_job`. Последовательность отката (`update_task_stage`
`notify_stage_change``plane_notify_stage` → [`set_issue_in_progress` для
тестера] → проверка `_developer_retry_count` < `MAX_DEVELOPER_RETRIES`
`enqueue_job`/`send_telegram`), значения `AdvanceResult` (`rolled_back_to`,
`enqueued_agent`, `enqueued_job_id`, `alerted`) и Plane-комментарии — без
изменений.
### Почему отдельный модуль, а не inline в `stage_engine`
- Тестируемость: парсер покрывается юнит-тестами `tests/test_review_parse.py`
изолированно от тяжёлого `advance_stage`.
- Blast radius: вся парсинг-логика (и её исключения) физически отделена от
hot-path ядра; ядро только подставляет строку и делает try-around-граничный
fallback.
- Согласованность с уже принятым паттерном defensive-парсеров
(`frontmatter.py`).
### Почему НЕ переиспользуется `frontmatter.read_frontmatter_value`
Тот хелпер читает одиночное значение из YAML-frontmatter. Здесь нужно извлекать
**тело markdown** (подсекции `## Findings`/`### P0`, фрагменты `## Вывод pytest`),
а не frontmatter-ключ. Это другая задача парсинга; общий контракт «never raise»
повторяется намеренно (как уже зафиксировано в ORCH-016/ADR-001 §5 — слияние
парсеров отдельной задачей).
### Почему per-work-item ADR, а не глобальный
Изменение НЕ добавляет гейт/стадию/компонент и НЕ меняет топологию или реестр
`QG_CHECKS` — это обогащение содержимого `task_desc` в существующих rollback-ветках
плюс вспомогательный модуль. По прецеденту ORCH-047/ADR-001 такого класса правки
фиксируются per-work-item ADR. Глобальный `docs/architecture/adr/` не требуется.
### Альтернативы (отклонены)
- **Inline-парсинг прямо в `stage_engine`** — отклонено: раздувает ядро, хуже
тестируется, исключения ближе к hot-path.
- **Менять промпты reviewer/tester, чтобы они сами клали суть в `task_desc`** —
отклонено: `task_desc` формирует ядро, а не агент; зависит от дисциплины двух
агентов вместо детерминированного кода; шире поверхность регрессии.
- **Передавать весь файл целиком в `task_desc`** — отклонено: раздувает промпт
developer-агента и стоимость токенов; теряется фокус на must-fix. Усечение по
P0/P1 + лимит решает проблему «испорченного телефона» дешевле.
## Последствия
- **Плюс:** developer-агент видит суть претензий (P0/P1, причина FAIL) сразу в
`.task-dev.md`; меньше повторных заворотов, экономия retry-бюджета и токенов на
всех проектах общего инстанса.
- **Плюс:** при битом/отсутствующем артефакте поведение не хуже текущего (ссылка
сохраняется); ссылка на полный файл присутствует всегда (AC-3).
- **Плюс:** изменение аддитивное — публичные сигнатуры (`advance_stage`, `_run_qg`,
`check_*`), реестр `QG_CHECKS`, webhook-пути и `build_status_comment` не
затрагиваются; снапшот `test_qg_registry_snapshot` остаётся зелёным (AC-7).
- **Минус/ограничение:** парсинг тела markdown чувствительнее к формату артефактов,
чем чтение одного frontmatter-ключа. Митигировано: распознавание P0/P1 устойчиво
к регистру/тире; при несовпадении формата — пустой результат и fallback на
ссылку (никогда не исключение).
- **Минус:** усечение лимитом может обрезать длинные findings — приемлемо, полный
контекст остаётся в файле, ссылка сохранена.
- **Self-hosting риск:** правка ядра в общем прод-контейнере. Обязателен прогон
`deploy-staging` (8501) перед прод-деплоем; прод-контейнер `orchestrator` (8500)
не перезапускать в рамках разработки/тестинга. Граничный риск — исключение из
парсера в `advance_stage`; закрыт контрактом «never raise» + юнит-кейсами на
битый/пустой/отсутствующий ввод (AC-4, AC-5).
## Влияние на документацию (golden source)
В PR разработки (вместе с кодом) обновить:
- `docs/architecture/README.md` — раздел **Stage Engine** / **Откаты**: упомянуть,
что `task_desc` при заворотах reviewer/tester несёт дословные findings + ссылку,
и новый модуль `src/review_parse.py` (defensive, never-raise).
- `CHANGELOG.md` — запись ORCH-046.
- `docs/architecture/internals.md` — по усмотрению reviewer, если детализируется
поток отката.
## Связи
- BRD/ТЗ/AC: `docs/work-items/ORCH-046/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`.
- Образцы паттерна «never raise»: `src/frontmatter.py`,
`src/qg/checks.py::_parse_tests_verdict`.
- Каноны артефактов: `.openclaw/agents/reviewer.md` (`12-review.md` `## Findings`),
`.openclaw/agents/tester.md` (`13-test-report.md` `result:` + тело).
- Прецедент per-work-item ADR на правку парсинга: ORCH-047/ADR-001.
- Технические риски: `docs/work-items/ORCH-046/10-tech-risks.md`.
- Staging-страховка: `docs/architecture/adr/adr-0003-staging-gate.md`.

View File

@@ -0,0 +1,29 @@
# Технические риски — ORCH-046
Work Item ID: ORCH-046
Stage: architecture
Author: architect
Date: 2026-06-06
Связано: `06-adr/ADR-001-embed-findings-in-task-desc.md`.
| # | Риск | Вероятн. | Влияние | Митигация | Контроль (AC/тест) |
|---|------|----------|---------|-----------|--------------------|
| R-1 | Исключение из парсера всплывает в `advance_stage` → встаёт конвейер ВСЕХ проектов (self-hosting, общий инстанс) | Низк. | **Критич.** | Контракт «never raise» в `review_parse.py`; вызов в `stage_engine` обёрнут так, что пустой результат → fallback на прежнюю ссылку-строку | AC-4; юнит-кейсы «нет файла / битый YAML / пустой / только P3» в `tests/test_review_parse.py` |
| R-2 | Регрессия в последовательности отката или полях `AdvanceResult` (меняется не только `task_desc`) | Низк. | Высок. | Жёсткий инвариант ТЗ §3.3: трогать ТОЛЬКО строку `task_desc`; порядок вызовов и условия retry неизменны | AC-6; существующие `tests/test_stage_engine.py` (rollback/retry) зелёные |
| R-3 | Парсер чувствителен к формату артефактов: дрейф `12-review.md`/`13-test-report.md` → пустой результат | Сред. | Низк. | Распознавание P0/P1 устойчиво к регистру/тире; при несовпадении → `""` + fallback на ссылку (деградация, не отказ) | AC-1/AC-2/AC-4 |
| R-4 | Раздувание `task_desc` длинными findings → рост стоимости/потеря фокуса developer-агента | Сред. | Низк. | Лимиты `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (~2000) + маркер `…(truncated)`; only P0/P1 (P2/P3 отброшены) | AC-1; проверка усечения в юнит-тестах |
| R-5 | Случайный выход за out-of-scope (правка `check_*`, `QG_CHECKS`, сигнатур, webhooks, `build_status_comment`) | Низк. | Сред. | Явный out-of-scope в ТЗ §4/§6; ревью diff | AC-7; `test_qg_registry_snapshot` зелёный |
| R-6 | Прод-деплой self без страховки staging | Низк. | **Критич.** | Обязательная стадия `deploy-staging` (8501); прод `orchestrator` (8500) не рестартить в разработке/тестинге | adr-0003; стадийный гейт `check_staging_status` |
| R-7 | Дублирование defensive-парсинга (3-й модуль рядом с `frontmatter.py` и `_parse_tests_verdict`) → техдолг | Сред. | Низк. | Осознанно принято (как ORCH-016/ADR-001 §5): малый blast radius важнее DRY; слияние парсеров — отдельная follow-up задача | — (техдолг, не блокер) |
## Заметки
- **Граничный try в ядре.** Даже при контракте «never raise» в `review_parse`,
вызов в `stage_engine` следует считать недоверенным: подстановка результата в
`task_desc` не должна зависеть от внутренней корректности парсера. Fallback на
ссылку-строку обязателен и при пустом результате, и при любой неожиданности.
- **Эскалация не требуется.** Изменение укладывается в принципы (минимум
зависимостей, raw-парсинг без новых либ, без новых компонентов/стадий/QG).
Лейбл `arch:major-change` НЕ ставится; возврат в Анализ не требуется — ТЗ
реализуемо без нарушения принципов.

View File

@@ -0,0 +1,83 @@
---
type: review
work_item_id: ORCH-046
verdict: APPROVED
version: 1
---
# Review ORCH-046
## Summary
Правка ядра «вариант A»: при заворотах на `development` `task_desc` теперь несёт
**дословный must-fix текст** (P0/P1 ревьюера, причина FAIL тестера) вместо одной
ссылки на файл. Извлечение вынесено в новый defensive-модуль `src/review_parse.py`
с контрактом «never raise»; две rollback-ветки `_handle_qg_failure_rollbacks`
встраивают текст и сохраняют ссылку как «Полный контекст», при пустом/битом
артефакте — graceful-фоллбэк на прежнюю строку.
Реализация полностью соответствует ТЗ (`02-trz.md`), ADR-001 и всем критериям
приёмки. Документация обновлена в этом же PR. Тесты зелёные (`461 passed`).
Проверено по осям:
**1. Соответствие ТЗ.** Сигнатуры `extract_review_findings`/`extract_test_failures`
точно как в ТЗ §2; never-raise, логирование на `logger.debug`, модульные лимиты
`MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS`, отбрасывание frontmatter, устойчивость
P0/P1-заголовков к регистру/тире, пропуск плейсхолдеров `(если есть)`/`<…>`,
приоритет источников тестера (`## Вывод pytest` → FAIL-строки `## Результаты`
`## Итог`). Префикс `task_desc`, `reason` в tester-ветке, ссылка-фоллбэк — как
предписано §3. API/БД/QG не тронуты (§46).
**2. Соответствие ADR-001.** Отдельный модуль (blast radius), путь через
`get_worktree_path`, изоляция ядра (меняется только строка `task_desc`),
последовательность отката и поля `AdvanceResult` сохранены. Per-work-item ADR
обоснован. Реализация ⇄ решение совпадают.
**3. Качество кода.** Docstrings на всех публичных функциях; defensive `_read`
ловит `OSError`, внешний `try/except Exception` в обоих экстракторах гарантирует
never-raise (подтверждено кейсом на directory-path). Регэксп `_P01_HEADER_RE`
корректно отсекает ложные совпадения (`P05` и т.п.). Код читабелен, без дублей.
**4. Качество тестов.** `tests/test_review_parse.py` покрывает TC-01..08 (findings
есть / только P2-P3 / нет файла / битый YAML / усечение / регистр-тире / directory).
`tests/test_stage_engine.py::TestRollbackTaskDescEmbedding` проверяет встраивание
в обе ветки, graceful-фоллбэк, неизменность retry/rollback на 4-м заходе (alert
вместо enqueue). Содержательные, не тривиальные.
## Findings
### P0 — Blocker
- [ ] (нет)
### P1 — Must fix
- [ ] (нет)
### P2 — Should fix
- [ ] (нет)
## Соответствие критериям приёмки
- AC-1 (дословные P0/P1 в `task_desc`) — PASS: `Findings (P0/P1):\n{findings}`.
- AC-2 (причина тестера: `reason` + фрагмент тела) — PASS: `Причина: {reason}` + `Детали:`.
- AC-3 (ссылка на полный файл сохранена) — PASS: «Полный контекст»/fallback-ссылка в обеих ветках.
- AC-4 (graceful never-raise) — PASS: `""`→ссылка-фоллбэк, исключений нет (тесты TC-03/04/07/08, directory-path).
- AC-5 (тесты зелёные + новые юнит-тесты) — PASS: `461 passed`; все перечисленные кейсы присутствуют.
- AC-6 (retry/rollback не изменены) — PASS: TC-12 + существующие rollback-тесты зелёные.
- AC-7 (out-of-scope не затронут) — PASS: diff не касается `src/qg/checks.py`, `src/webhooks/*`, `usage.py`, `stages.py`, `main.py`; сигнатуры публичных функций не менялись.
- AC-8 (документация + ADR) — PASS: ADR-001 заведён, `CHANGELOG.md` и `docs/architecture/README.md` обновлены.
## Документация
Обновлена корректно и в том же PR (golden source соблюдён):
- `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md` — заведён (правка ядра).
- `CHANGELOG.md` — запись ORCH-046 в `[Unreleased] / Added`.
- `docs/architecture/README.md` — добавлен компонент **Review/Test Parsers** и раздел **Обогащение `task_desc` при заворотах (ORCH-046)**.
Изменение `src/` сопровождено обновлением документации — требование п.4/п.6 правил
агентов выполнено.
## Примечание (self-hosting)
Правка ядра в общем прод-инстансе. Перед прод-деплоем обязательна стадия
`deploy-staging` (8501) согласно ADR-001 / CLAUDE.md — это страховка следующих
стадий, не блокер ревью.

View File

@@ -0,0 +1,92 @@
---
type: test-report
work_item_id: ORCH-046
result: PASS
---
# Test Report — ORCH-046
Встраивание дословного must-fix текста findings reviewer/tester в `task_desc`
при заворотах на `development` (новый модуль `src/review_parse.py` + две
rollback-ветки `src/stage_engine.py`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (asyncio mode=AUTO)
- Ветка: feature/ORCH-046-stage-engine-pass-reviewer-tes
- Дата: 2026-06-06
## Результаты
| TC ID | Описание | Покрывает | Результат |
|-------|----------|-----------|-----------|
| TC-01 | `extract_review_findings` возвращает дословный P0/P1 текст | AC-1, AC-5 | PASS |
| TC-02 | `extract_review_findings``""` при только P2/P3 | AC-5 | PASS |
| TC-03 | `extract_review_findings``""` для отсутствующего файла | AC-4 | PASS |
| TC-04 | `extract_review_findings``""` для битого/без секции файла | AC-4, AC-5 | PASS |
| TC-05 | `extract_review_findings` усекает длинный текст с маркером truncated | AC-1 | PASS |
| TC-06 | `extract_test_failures` извлекает фрагмент тела (Вывод pytest/FAIL/Итог) | AC-2, AC-5 | PASS |
| TC-07 | `extract_test_failures``""` для отсутствующего файла | AC-4 | PASS |
| TC-08 | `extract_test_failures``""` для битого/пустого отчёта | AC-4, AC-5 | PASS |
| TC-09 | reviewer REQUEST_CHANGES → `task_desc` содержит P0/P1 + ссылку | AC-1, AC-3 | PASS |
| TC-10 | tester FAIL → `task_desc` содержит reason + фрагмент + ссылку | AC-2, AC-3 | PASS |
| TC-11 | graceful fallback при отсутствующем/битом файле (обе ветки) | AC-4, AC-3 | PASS |
| TC-12 | rollback/retry поведение неизменно (alert на 4-й заход, поля AdvanceResult) | AC-6 | PASS |
| TC-13 | Реестр `QG_CHECKS` не изменён (snapshot), гейты нетронуты | AC-7 | PASS |
| TC-14 | Полный регресс существующего набора зелёный | AC-5, AC-6, AC-7 | PASS |
Сопоставление TC ↔ тесты:
- TC-01..08 → `tests/test_review_parse.py` (`TestExtractReviewFindings`, `TestExtractTestFailures`), 14 кейсов, все PASS.
- TC-09..12 → `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`, все PASS.
- TC-13 → `tests/test_qg_registry_snapshot.py` (registry/callables/transitions snapshot), все PASS.
- TC-14 → полный прогон `pytest tests/`**461 passed**.
## Smoke test API (read-only, прод-инстанс не затронут)
| Endpoint | HTTP | Ответ |
|----------|------|-------|
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
| GET /status | 200 | active_tasks включает task 37 (ORCH-046, stage=testing) |
| GET /queue | 200 | counts: queued=0, running=1, failed=0; breaker=closed; preflight_ok=true |
> `curl` в окружении отсутствует — smoke выполнен через `urllib`. Только GET-запросы,
> деструктивных операций над прод-контейнером не выполнялось (self-hosting safety).
## Вывод pytest
```
============================= test session starts ==============================
platform linux -- Python 3.12.13, pytest-8.3.3, pluggy-1.6.0
rootdir: .../feature_ORCH-046-stage-engine-pass-reviewer-tes
configfile: pytest.ini
plugins: anyio-4.13.0, asyncio-0.23.8
asyncio: mode=Mode.AUTO
...
======================== 461 passed, 1 warning in 7.59s ========================
```
Прицельный прогон ORCH-046 (`test_review_parse.py` + `test_stage_engine.py` +
`test_qg_registry_snapshot.py`): **53 passed**.
Единственный warning — преэкзистентный `PydanticDeprecatedSince20` в `src/config.py`
(не связан с ORCH-046).
## Покрытие критериев приёмки
| AC | Критерий | Подтверждение | Статус |
|----|----------|---------------|--------|
| AC-1 | Дословные P0/P1 в `task_desc` | TC-01, TC-09 | PASS |
| AC-2 | Причина тестера (reason + фрагмент) в `task_desc` | TC-06, TC-10 | PASS |
| AC-3 | Ссылка на полный файл сохранена | TC-09, TC-10, TC-11 | PASS |
| AC-4 | Парсер graceful (never-raise) | TC-03, TC-04, TC-07, TC-08, TC-11 | PASS |
| AC-5 | Тесты зелёные + новые юнит-тесты | TC-14 (461 passed) | PASS |
| AC-6 | Retry/rollback не изменены | TC-12 | PASS |
| AC-7 | Out-of-scope не затронут | TC-13 | PASS |
| AC-8 | Документация + ADR | проверено reviewer (12-review.md, APPROVED) | PASS |
## Итог
**PASS** — все 14 TC из тест-плана зелёные, полный регресс 461 passed,
smoke API 200 по всем эндпоинтам, прод-инстанс здоров. Все критерии приёмки
выполнены. Задача готова к стадии `deploy-staging` (8501) — обязательной
страховке self-hosting перед прод-деплоем.

View File

@@ -0,0 +1,90 @@
---
staging_status: FAILED
timestamp: 2026-06-06T04:47:45Z
base_url: http://localhost:8501
mode: stub
result: 9/10 checks PASS
failed_checks: [B6]
---
# Staging Gate Log — ORCH-046
Staging test suite **FAILED**. Exit code 1 (9/10 checks passed).
## Verdict
The staging gate is **red**: one check failed (`B6`). Per pipeline policy a
non-zero staging suite is `staging_status: FAILED` → rollback to `development`.
## Failed check
```
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent
[sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
```
**What it means.** The staging container's project registry is **not isolated**:
it sees the production projects `enduro-trails` (ET) and `orchestrator` (ORCH),
and the `orchestrator-sandbox` (SANDBOX) project is **absent**. This violates the
hard isolation invariant for staging (`docs/operations/INFRA.md`: «Staging видит
ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция»). The staging gate exists
precisely to catch this class of safety breach before any prod deploy of the
self-hosting orchestrator.
**Triage note (for humans).** This is a **staging environment / configuration**
issue — the staging container's `ORCH_PROJECTS_JSON` is pointing at the prod
registry instead of the sandbox-only registry. It is **not** a code regression
introduced by the ORCH-046 changeset (which only touches `src/review_parse.py`
and rollback `task_desc` enrichment). However, the gate is authoritative and red,
so the work item cannot pass to `deploy`. Fix the staging `.env.staging` /
`ORCH_PROJECTS_JSON` to expose only SANDBOX, re-run the staging suite, and the
gate will go green.
> ⚠️ Safety note: the first run aborted at `A3` because `ORCH_STAGING` was not
> set in the runner env (the script's guard against accidentally hitting prod).
> Re-run with `ORCH_STAGING=true` against the staging URL (8501) executed the
> full suite. Prod (8500) was never touched.
## Full suite output
```
============================================================
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-06T04:47:27.628664+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', '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}]
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
[Block C] E2E (mode=stub)
· C7: Creating issue in SANDBOX project...
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4]
· 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)...
✓ PASS C9a Branch appears in orchestrator-sandbox [branch=feature/SANDBOX-011-staging-check-e2e-20260606t044]
· C9b: Checking staging job queue for analyst job (up to 30s)...
· (Plane comment check skipped: bot-tokens not added to SANDBOX project)
✓ PASS C9b Analyst job enqueued in staging queue [job_id=7, status=queued, agent=analyst]
[CLEANUP]
✓ PASS CLEANUP: deleted branch 'feature/SANDBOX-011-staging-check-e2e-20260606t044' (HTTP 204)
✓ PASS CLEANUP: deleted Plane issue 2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4 (HTTP 204)
· CLEANUP DB: no task row found for plane_id=2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4
· CLEANUP DB dedup: no such table: events_dedup
============================================================
RESULT: 9/10 checks PASS
============================================================
EXIT_CODE=1
```

View File

@@ -0,0 +1,7 @@
# Business Request: check_tests_passed: gate must read result: field from test report
Work Item ID: ORCH-047
## Description
TBD

View File

@@ -0,0 +1,57 @@
# BRD — ORCH-047: check_tests_passed должен читать поле `result:` из тест-отчёта
## 1. Контекст и проблема
Quality Gate `check_tests_passed` (`src/qg/checks.py`, функция-парсер `_parse_tests_verdict`) гейтит переход `testing → deploy-staging`. Он читает машиночитаемый вердикт из YAML-frontmatter артефакта `13-test-report.md`.
**Дефект (обнаружен дев-агентом в ходе ORCH-17, подтверждён 05.06.2026):**
парсер читает ТОЛЬКО поля `verdict:` и `status:`. Однако промпт тестер-агента (`.openclaw/agents/tester.md`, строки 5156 и 7880) предписывает эмитить машиночитаемое поле **`result: PASS|FAIL`** — и НЕ упоминает ни `verdict:`, ни `status:`.
В результате тестер, честно следующий своей инструкции (реальный отчёт ORCH-017: `result: PASS`, без `verdict:`/`status:`), упирается в ветку «ни verdict, ни status не заданы» → гейт возвращает `False` с причиной *"No machine-readable verdict/status in test report frontmatter"* → задача откатывается `testing → development`.
**Последствие:** ЛЮБАЯ задача, где тестер пишет `result: PASS` (то есть строго по своей инструкции), застревает в бесконечной петле `testing ↔ development` до исчерпания `MAX_DEVELOPER_RETRIES`. Именно это крутило ORCH-17. ORCH-016 прошёл раньше лишь потому, что его отчёт избыточно нёс И `verdict:`, И `result:`.
**Корень:** рассинхрон контракта. Гейт (потребитель) и промпт тестера (производитель) описывают разные имена машиночитаемого поля.
## 2. Бизнес-цель
Привести контракт гейта `check_tests_passed` в соответствие с тем, что тестер-агенту реально велено эмитить, чтобы корректные тест-отчёты (`result: PASS`) проходили гейт, а отрицательные (`result: FAIL`) — надёжно откатывали задачу. Устранить ложноотрицательные срабатывания, ломающие конвейер всех проектов.
## 3. Заинтересованные стороны
- **Owner / Стрим, Слава** — выявили дефект при разборе ORCH-17 (05.06).
- **Все проекты общего прод-инстанса** (orchestrator self-hosting + enduro-trails) — потребители shared quality-gate. Это SHARED-изменение, влияет на всех.
- **Тестер-агент** — производитель `13-test-report.md`.
## 4. Объём работ (scope)
### В объёме
- `_parse_tests_verdict` читает `result:` как первоклассное машиночитаемое поле НАРАВНЕ с `verdict:` и `status:`.
- Семантика приоритетов сохраняется и распространяется на все три поля:
- negative-токен в ЛЮБОМ из трёх (`result`/`verdict`/`status`) → FAIL и авторитетен (перебивает positive в другом поле);
- при отсутствии negative — positive-токен в ЛЮБОМ из трёх → PASS;
- ни одно из трёх полей не задано → FAIL (нет машиночитаемого вердикта);
- заданы, но не распознаны → FAIL.
- Обратная совместимость: отчёты, несущие только `verdict:`/`status:` (стиль enduro-trails ET-001…ET-014, ORCH-016), продолжают работать ровно как раньше.
- **ADR** на изменение семантики shared testing-гейта (правило 2 CLAUDE.md — обязательно для сквозного изменения).
- Обновление документации: `docs/architecture/README.md` (строка про машинные ключи вердикт-парсера), `CHANGELOG.md`.
### Вне объёма
- Изменение промпта тестера (`.openclaw/agents/tester.md`). Контракт приводится со стороны гейта к тому, что тестеру УЖЕ велено эмитить; промпт не трогаем.
- Изменение других гейтов (`check_reviewer_verdict`, `check_deploy_status`, `check_staging_status`) — у них свои поля (`verdict:`, `deploy_status:`, `staging_status:`), они вне этого дефекта.
- Изменения ORCH-017 (про ссылки) — это отдельный work item.
## 5. Ограничения и риски
- **SHARED quality-gate, общий прод-инстанс.** Изменение затрагивает enduro-trails наравне с orchestrator. Регресс недопустим: набор положительных/отрицательных токенов и поведение для старого формата (`verdict:`/`status:`) должны остаться неизменными.
- **Self-hosting.** Орк правит сам себя; деплой проходит через обязательную стадию `deploy-staging` (8501). Прод-контейнер `orchestrator` (8500) не ронять.
- Изменение читает только frontmatter, никогда не прозу (канон гейтов из `docs/architecture/README.md`).
- Парсер не должен бросать исключения ни при каком вводе (битый YAML, пустой файл, frontmatter-не-mapping) → всегда `(False, reason)`.
## 6. Эталонный код
Дев-агент уже написал референс-реализацию в ветке `feature/ORCH-017` (`src/qg/checks.py` + `tests/test_qg.py`, 23 теста). Его допустимо использовать как ориентир, но оформить чисто через данный work item с собственным ADR.
## 7. Критерий успеха
Тест-отчёт с одним лишь `result: PASS` проходит гейт `check_tests_passed`; с `result: FAIL` — нет. Старый формат (`verdict:`/`status:`) не регрессирует. Все pytest зелёные. ADR заведён.

View File

@@ -0,0 +1,68 @@
# ТЗ — ORCH-047: `_parse_tests_verdict` читает `result:` наравне с `verdict:`/`status:`
## 1. Задействованные модули `src/`
| Файл | Что меняется |
|------|--------------|
| `src/qg/checks.py` | Функция `_parse_tests_verdict` (стр. ~223265). Добавить чтение поля `result:` из frontmatter и включить его в проверку токенов наравне с `verdict:`/`status:`. Обновить докстринг функции и `check_tests_passed`. |
Точка входа `check_tests_passed(repo, work_item_id, branch)` (стр. ~182) и реестр `QG_CHECKS` НЕ меняются (сигнатура и имя гейта те же).
## 2. Требуемое поведение `_parse_tests_verdict`
Вход — строковое тело `13-test-report.md`. Выход — `tuple[bool, str]`.
1. Нет frontmatter (`content` не начинается с `---`) → `(False, "No YAML frontmatter ...")`.
2. Frontmatter некорректен (split по `---` даёт < 3 частей) → `(False, "Malformed YAML frontmatter ...")`.
3. YAML не парсится → `(False, "Invalid YAML frontmatter ...: <e>")` (никогда не raise).
4. YAML не mapping → `(False, "Malformed YAML frontmatter ... (not a mapping)")`.
5. Прочитать три поля, нормализовать (`str(...).upper().strip()`, защита от `None`):
- `verdict`
- `status`
- **`result`НОВОЕ**
6. Если ВСЕ три пусты → `(False, "No machine-readable verdict/status/result in test report frontmatter")`.
7. Собрать объединённую строку полей `fields = f"{verdict} {status} {result}"`.
8. Если в `fields` встречается ЛЮБОЙ negative-токен (`_TESTS_NEGATIVE_TOKENS`) → `(False, "Test verdict: <значение> (<NEG>)")`. **Negative авторитетен** — проверяется ПЕРВЫМ, перебивает любой positive.
9. Иначе если встречается ЛЮБОЙ positive-токен (`_TESTS_POSITIVE_TOKENS`) → `(True, "Test verdict: <значение> (PASS)")`.
10. Иначе (заданы, но не распознаны) → `(False, "No recognized PASS verdict in frontmatter (...)")`.
Наборы токенов НЕ изменяются (важно для обратной совместимости с enduro-trails):
```python
_TESTS_NEGATIVE_TOKENS = ("BLOCKED", "FAILED", "FAIL", "REQUEST_CHANGES", "REJECT", "RED")
_TESTS_POSITIVE_TOKENS = ("PASSED", "PASS", "READY-TO-DEPLOY", "READY_TO_DEPLOY", "GREEN", "APPROVED")
```
> Примечание для разработчика (порядок токенов): negative-список проверяется раньше positive — это даёт авторитетность отрицания. Внутри positive-набора `"PASSED"` идёт перед `"PASS"` лишь для аккуратного reason-текста; на результат (bool) порядок не влияет, т.к. это подстрочный поиск.
## 3. Контракт поля (golden source)
После изменения машиночитаемыми полями testing-гейта считаются **три равноправных**: `result:` (канон промпта тестера), `verdict:`, `status:` (легаси/enduro-trails). Достаточно ЛЮБОГО одного. Это и есть приведение гейта к тому, что тестеру велено эмитить в `.openclaw/agents/tester.md` (`result: PASS|FAIL`).
## 4. Изменения API
Нет. HTTP-эндпоинты (`/health`, `/status`, `/queue`, вебхуки) не затрагиваются. Сигнатуры функций гейта не меняются.
## 5. Изменения схемы БД
Нет.
## 6. Требования к новым QG checks
Новых гейтов нет. Меняется внутренняя логика существующего `check_tests_passed` (через `_parse_tests_verdict`). Реестр `QG_CHECKS` без изменений → снапшот-тест `tests/test_qg_registry_snapshot.py` должен остаться зелёным.
## 7. Артефакты pipeline (создать/обновить в этом PR)
- `docs/work-items/ORCH-047/06-adr/ADR-001-*.md`**обязательно** (правило 2 CLAUDE.md): ADR на изменение семантики SHARED testing-гейта (влияет на все проекты общего инстанса). Заводит архитектор.
- `docs/architecture/README.md` — обновить строку о вердикт-парсере (раздел «Plane Sync», п. про машинные ключи): для testing-гейта перечислить `result:`/`verdict:`/`status:`.
- `CHANGELOG.md` — запись `fix:` про ORCH-047.
- `tests/test_qg.py` — добавить кейсы на `result:` (см. `04-test-plan.yaml`).
## 8. Нефункциональные требования
- Парсер не бросает исключений ни на каком вводе.
- Изменение читает только frontmatter, не прозу (канон гейтов).
- Полная обратная совместимость: существующие тесты `TestCheckTestsPassed` остаются зелёными без правок (кроме, возможно, переименования reason-строки в п.6 BRD — текст причины «No machine-readable verdict/status...» обновляется на «...verdict/status/result...», соответствующий ассерт при наличии обновить).
## 9. Деплой
Self-hosting: стандартный путь через `deploy-staging` (8501) перед прод-деплоем. Прод-контейнер `orchestrator` (8500) не перезапускать в рамках разработки/тестинга.

View File

@@ -0,0 +1,68 @@
# Критерии приёмки — ORCH-047
Каждый критерий имеет однозначное условие PASS/FAIL.
## AC-01 — `result: PASS` проходит гейт (главный кейс ORCH-17)
- **Дано:** `13-test-report.md` с frontmatter, содержащим только `result: PASS` (без `verdict:`/`status:`).
- **Ожидается:** `check_tests_passed(...)``(True, ...)`, в reason присутствует «PASS».
- **PASS:** возвращается True. **FAIL:** возвращается False.
## AC-02 — `result: FAIL` откатывает задачу
- **Дано:** frontmatter с `result: FAIL` (без `verdict:`/`status:`).
- **Ожидается:** `(False, ...)`, reason содержит токен отрицания (`FAIL`).
- **PASS:** False. **FAIL:** True.
## AC-03 — Negative авторитетен поверх positive (в т.ч. между полями)
- **Дано:** `result: PASS`, но `verdict: BLOCKED` (или `status: failed`).
- **Ожидается:** `(False, ...)`, reason упоминает negative-токен (`BLOCKED`/`FAILED`).
- **PASS:** False. **FAIL:** True.
## AC-04 — Positive в любом из трёх полей даёт PASS
- **Дано (каждый подкейс отдельно):**
- только `verdict: PASS`;
- только `status: PASSED`;
- только `result: ready-to-deploy`.
- **Ожидается:** все три → `(True, ...)`.
- **PASS:** все True. **FAIL:** хоть один False.
## AC-05 — Обратная совместимость (enduro-trails / ORCH-016)
- **Дано:** существующие реальные формы из `TestCheckTestsPassed`:
- `verdict: PASS` + `status: pass`;
- `verdict: PASS — ready-to-deploy`;
- `verdict: ready-to-deploy` + `status: PASSED`;
- `verdict: stage:ready-to-deploy` + `status: pass`;
- `verdict: BLOCKED` + проза «23 passed».
- **Ожидается:** результаты идентичны прежним (PASS-кейсы → True, BLOCKED → False). Старые тесты `TestCheckTestsPassed` зелёные.
- **PASS:** поведение не изменилось. **FAIL:** любой регресс.
## AC-06 — Ни одно из трёх полей не задано → FAIL
- **Дано:** frontmatter без `result`/`verdict`/`status` (например, только `type:`/`version:`); тело может содержать «Result: PASS» прозой.
- **Ожидается:** `(False, ...)`, причина про отсутствие машиночитаемого вердикта.
- **PASS:** False. **FAIL:** True.
## AC-07 — Только проза, без frontmatter → FAIL
- **Дано:** отчёт без YAML-frontmatter, в теле «Result: PASS / All tests passed».
- **Ожидается:** `(False, ...)`, причина про отсутствие frontmatter. Прозу не читаем.
- **PASS:** False. **FAIL:** True.
## AC-08 — Битый YAML → FAIL без исключения
- **Дано:** некорректный YAML во frontmatter.
- **Ожидается:** `(False, ...)` c упоминанием YAML/frontmatter, функция НЕ бросает исключение.
- **PASS:** False и нет raise. **FAIL:** raise или True.
## AC-09 — Отчёт отсутствует → FAIL
- **Дано:** файла `13-test-report.md` нет.
- **Ожидается:** `(False, "...not found...")`.
- **PASS:** False. **FAIL:** True.
## AC-10 — Реестр гейтов неизменен
- **Ожидается:** `QG_CHECKS` содержит ровно те же ключи, что и до изменения; `tests/test_qg_registry_snapshot.py` зелёный.
- **PASS:** снапшот совпал. **FAIL:** снапшот изменился.
## AC-11 — Документация и ADR обновлены (правило 2/6 CLAUDE.md)
- **Ожидается:** заведён `docs/work-items/ORCH-047/06-adr/ADR-001-*.md`; обновлены `docs/architecture/README.md` (вердикт-парсер testing-гейта) и `CHANGELOG.md`.
- **PASS:** все три присутствуют и описывают изменение. **FAIL:** что-либо отсутствует → REQUEST_CHANGES на review.
## AC-12 — Полный регресс зелёный
- **Ожидается:** `pytest tests/ -q` — все тесты PASS.
- **PASS:** exit code 0. **FAIL:** любой упавший тест.

View File

@@ -0,0 +1,97 @@
work_item: ORCH-047
module_under_test: src/qg/checks.py::_parse_tests_verdict (via check_tests_passed)
test_file: tests/test_qg.py
notes: >
Добавить в класс TestCheckTestsPassed. Шаблон записи отчёта — существующий
хелпер self._write(dir, content). Наборы токенов не меняются; проверяем, что
поле result: теперь равноправно с verdict:/status:, а старые кейсы не регрессируют.
tests:
- id: TC-01
type: unit
description: "result: PASS без verdict/status -> гейт PASS (главный кейс ORCH-17, AC-01)"
module: tests/test_qg.py
fixture_frontmatter: "---\ntype: test-report\nresult: PASS\n---\n\n# Test Report\n"
expected: PASS
- id: TC-02
type: unit
description: "result: FAIL без verdict/status -> гейт FAIL, reason содержит FAIL (AC-02)"
module: tests/test_qg.py
fixture_frontmatter: "---\nresult: FAIL\n---\n\nbody\n"
expected: FAIL
- id: TC-03
type: unit
description: "result: PASS, но verdict: BLOCKED -> negative авторитетен -> FAIL (AC-03)"
module: tests/test_qg.py
fixture_frontmatter: "---\nresult: PASS\nverdict: BLOCKED\n---\n\n23 passed\n"
expected: FAIL
- id: TC-04
type: unit
description: "result: PASS, но status: failed -> negative авторитетен -> FAIL (AC-03)"
module: tests/test_qg.py
fixture_frontmatter: "---\nresult: PASS\nstatus: failed\n---\n\nbody\n"
expected: FAIL
- id: TC-05
type: unit
description: "result: ready-to-deploy (positive-токен, без слова PASS) -> PASS (AC-04)"
module: tests/test_qg.py
fixture_frontmatter: "---\nresult: ready-to-deploy\n---\n\nbody\n"
expected: PASS
- id: TC-06
type: unit
description: "Только verdict: PASS (легаси) -> PASS, без регресса (AC-05)"
module: tests/test_qg.py
fixture_frontmatter: "---\nverdict: PASS\nstatus: pass\n---\n\nbody\n"
expected: PASS
- id: TC-07
type: unit
description: "verdict: BLOCKED + проза '23 passed' (ET-013 баг) -> FAIL, без регресса (AC-05)"
module: tests/test_qg.py
fixture_frontmatter: "---\nverdict: BLOCKED\n---\n\nTests: 23 passed, 0 failed.\n"
expected: FAIL
- id: TC-08
type: unit
description: "Ни result, ни verdict, ни status; тело с прозой 'Result: PASS' -> FAIL (AC-06)"
module: tests/test_qg.py
fixture_frontmatter: "---\ntype: test-report\nversion: 1\n---\n\nResult: PASS\n"
expected: FAIL
- id: TC-09
type: unit
description: "Нет frontmatter, проза 'Result: PASS' -> FAIL (AC-07)"
module: tests/test_qg.py
fixture_frontmatter: "# Test Report\n\nResult: PASS\nAll tests passed.\n"
expected: FAIL
- id: TC-10
type: unit
description: "Битый YAML во frontmatter -> FAIL без исключения, reason про YAML/frontmatter (AC-08)"
module: tests/test_qg.py
fixture_frontmatter: "---\nresult: [unclosed\n : : :\n---\n\nbody PASS\n"
expected: FAIL
- id: TC-11
type: unit
description: "Файл 13-test-report.md отсутствует -> FAIL, reason 'not found' (AC-09)"
module: tests/test_qg.py
fixture_frontmatter: null
expected: FAIL
- id: TC-12
type: unit
description: "Реестр QG_CHECKS не изменился -> снапшот зелёный (AC-10)"
module: tests/test_qg_registry_snapshot.py
expected: PASS
- id: TC-13
type: integration
description: "Полный регресс pytest tests/ -q зелёный, существующий TestCheckTestsPassed без правок логики (AC-05, AC-12)"
module: tests/
expected: PASS

View File

@@ -0,0 +1,80 @@
# ADR-001: testing-гейт читает `result:` наравне с `verdict:`/`status:`
- **Статус:** Accepted
- **Дата:** 2026-06-05
- **Задача:** ORCH-047
- **Область:** SHARED quality-gate `check_tests_passed` (общий прод-инстанс: orchestrator + enduro-trails)
## Контекст
Quality Gate `check_tests_passed` (`src/qg/checks.py`, парсер `_parse_tests_verdict`) гейтит
переход `testing → deploy-staging`, читая машиночитаемый вердикт ТОЛЬКО из YAML-frontmatter
артефакта `13-test-report.md` (канон гейтов: frontmatter, никогда не проза — см.
`docs/architecture/README.md`).
Существует рассинхрон контракта между производителем и потребителем вердикта:
- **Потребитель** (`_parse_tests_verdict`) читает поля `verdict:` и `status:`.
- **Производитель** (`.openclaw/agents/tester.md`, строки 5156, 7880) предписывает тестеру
эмитить машиночитаемое поле **`result: PASS|FAIL`** и НЕ упоминает `verdict:`/`status:`.
Тестер, честно следуя своей инструкции, пишет `result: PASS` без `verdict:`/`status:`. Парсер
попадает в ветку «ни verdict, ни status не заданы» → `(False, "No machine-readable
verdict/status…")` → откат `testing → development` и петля до исчерпания
`MAX_DEVELOPER_RETRIES`. Это наблюдалось на ORCH-17; ORCH-016 прошёл лишь потому, что его отчёт
избыточно нёс И `verdict:`, И `result:`.
Корень — несовпадение имён поля контракта, а не логики токенов. Наборы positive/negative-токенов
исправны и менять их нельзя (обратная совместимость с реальными отчётами enduro-trails
ET-001…ET-014).
## Решение
Привести контракт гейта к тому, что тестеру УЖЕ велено эмитить — со стороны гейта, не трогая
промпт тестера.
1. `_parse_tests_verdict` читает **три равноправных** машиночитаемых поля из frontmatter:
`result:` (канон промпта тестера), `verdict:`, `status:` (легаси/enduro-trails). Достаточно
ЛЮБОГО одного непустого.
2. Семантика приоритетов сохраняется и распространяется на все три поля через объединённую строку
`fields = f"{verdict} {status} {result}"`:
- negative-токен (`_TESTS_NEGATIVE_TOKENS`) в любом поле → FAIL и **авторитетен** (проверяется
первым, перебивает positive в другом поле);
- иначе positive-токен (`_TESTS_POSITIVE_TOKENS`) в любом поле → PASS;
- ни одно из трёх не задано → FAIL («No machine-readable verdict/status/result…»);
- заданы, но не распознаны → FAIL.
3. Наборы токенов **не изменяются**.
4. Парсер не бросает исключений ни на каком вводе (битый YAML, пустой файл, frontmatter-не-mapping)
→ всегда `(False, reason)`.
5. Сигнатура `check_tests_passed`, имя гейта и реестр `QG_CHECKS` **не меняются** — снапшот
`tests/test_qg_registry_snapshot.py` остаётся зелёным.
### Альтернативы (отклонены)
- **Править промпт тестера** (`verdict:` вместо `result:`) — отклонено: контракт уже задокументирован
для тестера как `result:`; единичная правка гейта дешевле и не требует переучивать агента, плюс
ломала бы совместимость со старыми отчётами, где встречается `verdict:`/`status:`.
- **Глобальный ADR в `docs/architecture/adr/`** — не требуется: изменение не добавляет гейт/стадию/
компонент и не меняет топологию; это приведение парсинга существующего гейта к контракту. Канон
гейтов в README обновляется точечно.
## Последствия
- **Плюс:** корректные отчёты `result: PASS` проходят гейт; `result: FAIL` надёжно откатывает.
Петля `testing ↔ development` устранена для всех проектов общего инстанса.
- **Плюс:** полная обратная совместимость — отчёты только с `verdict:`/`status:` работают как
раньше; существующие тесты `TestCheckTestsPassed` зелёные без правок (кроме обновления reason-текста
«…verdict/status…» → «…verdict/status/result…»).
- **Минус/ограничение:** число распознаваемых имён поля растёт до трёх — формально шире поверхность
«случайного PASS». Митигируется тем, что negative-токен авторитетен и читается только frontmatter.
- **SHARED-риск:** изменение затрагивает enduro-trails наравне с orchestrator. Регресс по наборам
токенов недопустим → они заморожены; покрытие — `04-test-plan.yaml` (AC-04/AC-05).
- **Self-hosting:** деплой строго через `deploy-staging` (8501); прод-контейнер `orchestrator`
(8500) не перезапускать в рамках разработки/тестинга.
## Связи
- BRD/ТЗ: `docs/work-items/ORCH-047/01-brd.md`, `02-trz.md`.
- Канон гейтов и вердикт-парсер: `docs/architecture/README.md`.
- Промпт-производитель: `.openclaw/agents/tester.md` (`result: PASS|FAIL`).
- adr-0003 (staging-гейт) — обязательная страховка перед прод-деплоем self.

View File

@@ -0,0 +1,10 @@
# Технические риски — ORCH-047
| # | Риск | Вероятность | Влияние | Митигация |
|---|------|-------------|---------|-----------|
| R-1 | Регресс набора токенов ломает enduro-trails (SHARED-гейт, общий прод-инстанс) | Низкая | Высокое | Наборы `_TESTS_NEGATIVE_TOKENS`/`_TESTS_POSITIVE_TOKENS` **заморожены** (не трогать). Покрытие AC-05 на реальных формах ET-001…ET-014 + ORCH-016. |
| R-2 | Новое поле `result:` расширяет поверхность ложного PASS | Низкая | Среднее | Negative-токен авторитетен (проверяется первым, перебивает positive). Читается только frontmatter, не проза (AC-03, AC-06, AC-07). |
| R-3 | Парсер бросает исключение на битом вводе → падение `_run_qg` | Низкая | Высокое | Defensive-контракт сохранён: любой ввод (нет frontmatter / битый YAML / не-mapping / пустой) → `(False, reason)`, никогда raise (AC-08). |
| R-4 | Незаметное изменение реестра гейтов | Очень низкая | Среднее | Сигнатура, имя гейта и `QG_CHECKS` неизменны; снапшот `tests/test_qg_registry_snapshot.py` зелёный (AC-10). |
| R-5 | Self-hosting: деплой роняет прод-контейнер всех проектов | Низкая | Высокое | Деплой только через `deploy-staging` (8501); прод `orchestrator` (8500) не перезапускать в dev/test (CLAUDE.md, adr-0003). |
| R-6 | Изменение поведения без обновления golden-source доки → REQUEST_CHANGES на review | Средняя | Низкое | ADR-001 заведён; `docs/architecture/README.md` (вердикт-парсер) обновлён архитектором; `CHANGELOG.md` — дев в том же PR (AC-11). |

View File

@@ -0,0 +1,62 @@
---
type: review
work_item_id: ORCH-047
verdict: APPROVED
version: 3
---
# Review ORCH-047
## Summary
Гейт `check_tests_passed` (через `_parse_tests_verdict`) теперь читает `result:` наравне с
`verdict:`/`status:`. Реализация точно соответствует ТЗ (`02-trz.md`), ADR-001 и критериям
приёмки. Независимый прогон: `pytest tests/ -q`**442 passed**; снапшот реестра гейтов не
изменился. Документация (README, ADR-001, CHANGELOG) обновлена в том же PR. Блокеров и
must-fix нет → APPROVED.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- [ ] Докстринг `check_tests_passed` (≈стр. 184) по-прежнему говорит «Gate the testing ->
deploy transition», тогда как фактический переход — `testing → deploy-staging`.
Несоответствие предсуществующее, этим PR не введено; чистая косметика, не блокирует.
## Соответствие ТЗ и AC
- **ТЗ §2** — все 10 правил поведения реализованы: чтение `result:` (стр. 261, нормализация
`str(...).upper().strip()` + защита от `None`); все три пусты → корректная reason-строка
«...verdict/status/result...» (стр. 263264); объединённая строка `fields = "{verdict}
{status} {result}"` (стр. 267); negative-токен проверяется ПЕРВЫМ и авторитетен
(стр. 268270); positive (стр. 271273); fallback на нераспознанные (стр. 275279).
Наборы `_TESTS_NEGATIVE_TOKENS`/`_TESTS_POSITIVE_TOKENS` не тронуты. ✅
- **ТЗ §4/§5/§6** — сигнатура `check_tests_passed`, имя гейта, `QG_CHECKS`, HTTP-API, схема БД
не изменены. Снапшот `tests/test_qg_registry_snapshot.py` зелёный (AC-10). ✅
- **AC-01..AC-09** — покрыты новыми кейсами в `TestCheckTestsPassed`: `result: PASS/FAIL`,
авторитетность negative между полями (`verdict: BLOCKED`, `status: failed` поверх
`result: PASS`), `result: ready-to-deploy`, отсутствие машинных полей (reason упоминает
`result`). Легаси-кейсы остались зелёными без правок логики (AC-05). ✅
- **AC-12** — `pytest tests/ -q` → 442 passed (независимый прогон ревьюера). ✅
## Соответствие ADR
- ADR-001 (`06-adr/ADR-001-result-field-in-tests-gate.md`): решение «три равноправных поля,
токены заморожены, negative авторитетен, реестр/сигнатура неизменны» полностью отражено
в коде.
- Глобальный ADR обоснованно не требуется (изменение не добавляет гейт/стадию/компонент,
не меняет топологию) — согласуется с конвенцией CLAUDE.md. SHARED-риск общего инстанса
(orchestrator + enduro-trails) учтён: токены заморожены, обратная совместимость покрыта
тестами.
## Документация
ОБНОВЛЕНА в том же PR (правило 2/6 CLAUDE.md, AC-11):
- `docs/architecture/README.md` — строка вердикт-парсера: для testing-гейта перечислены
`result:`/`verdict:`/`status:` + пометка про авторитетность negative. ✅
- `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md` — заведён. ✅
- `CHANGELOG.md` — запись в `Fixed` про ORCH-047. ✅

View File

@@ -0,0 +1,78 @@
---
type: test-report
work_item_id: ORCH-047
result: PASS
---
# Test Report — ORCH-047
`check_tests_passed` / `_parse_tests_verdict` читает `result:` наравне с `verdict:`/`status:`.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: feature/ORCH-047-check-tests-passed-gate-must-r
- Среда: dev worktree (прод-контейнер `orchestrator` :8500 не затронут)
- Дата: 2026-06-05
## Smoke test API (prod :8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | 200, активные задачи отдаются (ORCH-047 в testing) — OK |
| `GET /queue` | 200, counts/breaker/preflight в норме (running:1, failed:0) — OK |
## Результаты (план `04-test-plan.yaml`)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | `result: PASS` без verdict/status → PASS (AC-01) | `test_result_pass_passes` | PASS |
| TC-02 | `result: FAIL` → FAIL, reason содержит FAIL (AC-02) | `test_result_fail_fails` | PASS |
| TC-03 | `result: PASS` + `verdict: BLOCKED` → negative авторитетен → FAIL (AC-03) | `test_result_pass_but_verdict_blocked_fails` | PASS |
| TC-04 | `result: PASS` + `status: failed` → FAIL (AC-03) | `test_result_pass_but_status_failed_fails` | PASS |
| TC-05 | `result: ready-to-deploy` → PASS (AC-04) | `test_result_ready_to_deploy_passes` | PASS |
| TC-06 | Легаси `verdict: PASS` → PASS, без регресса (AC-05) | `test_verdict_pass_passes` | PASS |
| TC-07 | `verdict: BLOCKED` + проза «23 passed» → FAIL (AC-05) | `test_passed_count_in_body_but_blocked_verdict_fails` | PASS |
| TC-08 | Нет машинных полей, проза «Result: PASS» → FAIL (AC-06) | `test_no_machine_field_reason_mentions_result` | PASS |
| TC-09 | Нет frontmatter → FAIL (AC-07) | `test_no_frontmatter_fails` | PASS |
| TC-10 | Битый YAML → FAIL без исключения (AC-08) | `test_invalid_yaml_fails_no_exception` | PASS |
| TC-11 | Отчёт отсутствует → FAIL «not found» (AC-09) | `test_no_report` | PASS |
| TC-12 | Реестр `QG_CHECKS` неизменен (AC-10) | `test_qg_registry_snapshot.py` (3 теста) | PASS |
| TC-13 | Полный регресс зелёный (AC-05, AC-12) | `pytest tests/` | PASS |
## Покрытие критериев приёмки
| AC | Статус |
|----|--------|
| AC-01 `result: PASS` проходит | PASS |
| AC-02 `result: FAIL` откатывает | PASS |
| AC-03 negative авторитетен между полями | PASS |
| AC-04 positive в любом из трёх полей → PASS | PASS |
| AC-05 обратная совместимость (TestCheckTestsPassed) | PASS |
| AC-06 ни одно поле не задано → FAIL | PASS |
| AC-07 только проза без frontmatter → FAIL | PASS |
| AC-08 битый YAML → FAIL без raise | PASS |
| AC-09 отчёт отсутствует → FAIL | PASS |
| AC-10 реестр гейтов неизменен | PASS |
| AC-11 ADR/README/CHANGELOG обновлены | PASS |
| AC-12 полный регресс зелёный | PASS |
AC-11 проверено вручную:
- `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md` — присутствует.
- `docs/architecture/README.md` — строка вердикт-парсера перечисляет `result:`/`verdict:`/`status:`.
- `CHANGELOG.md` — запись `fix:` про ORCH-047.
## Вывод pytest
```
tests/test_qg.py ............................... TestCheckTestsPassed (все PASS,
включая новые test_result_* и легаси-кейсы)
tests/test_qg_registry_snapshot.py::test_tc20_qg_callables_unchanged PASSED
tests/test_qg_registry_snapshot.py::test_tc20_stage_transitions_unchanged PASSED
...
======================== 442 passed, 1 warning in 7.77s ========================
```
(1 warning — предсуществующий PydanticDeprecatedSince20 в `src/config.py`, не связан с ORCH-047.)
## Итог
PASS — все 13 TC и 12 AC выполнены, полный регресс зелёный (442 passed), smoke OK,
реестр гейтов не изменён. Задача готова к стадии deploy-staging.

View File

@@ -0,0 +1,83 @@
---
staging_status: FAILED
timestamp: 2026-06-05T21:30:45Z
base_url: http://localhost:8501
mode: stub
result: 9/10 checks PASS
exit_code: 1
---
# Staging Gate Log — ORCH-047
Staging test suite **FAILED**: 9/10 checks passed, exit code 1.
## Verdict
The live staging service on `:8501` is healthy and the full E2E pipeline ran
correctly against the **sandbox** project (issue created → webhook accepted →
branch created in `orchestrator-sandbox` → analyst job enqueued → cleanup OK).
The single failing check is **B6 — Registry isolation**: the project registry as
seen by the test harness still contains the production projects
(`enduro-trails`, `ORCH`) and does **not** isolate to the sandbox project only.
This violates the staging isolation requirement (CLAUDE.md: "staging — только
sandbox-проект"). Because the staging gate returned a non-zero exit code, the
machine verdict is `FAILED` and the task is rolled back to `development`.
### Notes for follow-up (development)
- B6 imports `src.projects.known_plane_project_ids()` and asserts the registry
contains the sandbox id (`8c5a3025-…`) while the prod ids
(`7a79f0a9-…` ET, `8da6aa25-…` ORCH) are absent. It observed
`sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)`.
- This is a staging-environment / registry-isolation signal, not a verdict on the
ORCH-047 code change itself (which targets the `check_tests_passed` gate).
Investigate whether the staging container's isolated project registry env is
loaded, or whether the harness's in-process registry import is reading the host
(`/repos/orchestrator`) prod env instead of the container's env.
- Deployer did **not** modify any production infrastructure, registry, `.env`,
or `docker-compose.yml` to alter this result (per deployer mandate).
## Full test output
```
============================================================
ORCH-33 Staging Check Suite
base_url : http://localhost:8501
mode : stub
utc_time : 2026-06-05T21:30:45.071676+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', '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}]
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
[Block C] E2E (mode=stub)
· C7: Creating issue in SANDBOX project...
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=5040c202-592f-45d0-9463-ca1e9944e6ba]
· 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)...
✓ PASS C9a Branch appears in orchestrator-sandbox [branch=feature/SANDBOX-010-staging-check-e2e-20260605t213]
· C9b: Checking staging job queue for analyst job (up to 30s)...
· (Plane comment check skipped: bot-tokens not added to SANDBOX project)
✓ PASS C9b Analyst job enqueued in staging queue [job_id=6, status=queued, agent=analyst]
[CLEANUP]
✓ PASS CLEANUP: deleted branch 'feature/SANDBOX-010-staging-check-e2e-20260605t213' (HTTP 204)
✓ PASS CLEANUP: deleted Plane issue 5040c202-592f-45d0-9463-ca1e9944e6ba (HTTP 204)
· CLEANUP DB: no task row found for plane_id=5040c202-592f-45d0-9463-ca1e9944e6ba
· CLEANUP DB dedup: no such table: events_dedup
============================================================
RESULT: 9/10 checks PASS
============================================================
EXIT_CODE=1
```

View File

@@ -0,0 +1,7 @@
# Business Request: staging B6 check reads registry from host worktree, not staging container
Work Item ID: ORCH-048
## Description
TBD

View File

@@ -0,0 +1,86 @@
# 01 — Business Requirements Document (BRD)
**Work Item:** ORCH-048
**Title:** staging B6 check reads registry from host worktree, not staging container
**Stage:** analysis
**Author:** analyst
**Date:** 2026-06-06
---
## 1. Контекст и проблема
`scripts/staging_check.py` — suite живых проверок staging-стенда orchestrator (порт 8501, ADR-0003). Деплоер запускает его на стадии `deploy-staging` и пишет `staging_status:` в `15-staging-log.md`. FAIL любого чека = откат на `development`.
Блок B содержит проверку **B6 «Registry: sandbox present, prod ET/ORCH absent»** — она должна подтверждать, что в staging-реестре проектов есть только sandbox-проект и НЕТ боевых проектов (enduro-trails / orchestrator). Это страховка изоляции: staging не должен обслуживать прод-проекты.
**B6 даёт ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`), хотя сама изоляция реестра в staging РАБОТАЕТ корректно.
### Root cause (подтверждён прямым запуском, Стрим, 06.06)
- Внутри контейнера `orchestrator-staging` `known_plane_project_ids()` корректно отдаёт `count=1, sandbox=True, ET=False, ORCH=False`. `.env.staging` верно задаёт `ORCH_PROJECTS_JSON` = только sandbox. **Изоляция реестра исправна.**
- Все остальные чеки (A1A3, B4, B5, блок C E2E) обращаются к работающему staging-инстансу по HTTP и **зелёные**.
- **B6 — единственный чек, который не ходит по HTTP, а импортирует Python-код локально.** В блоке B6 (строки ~263284) выполняется:
```python
sys.path.insert(0, "/repos/orchestrator") # ХОСТ-worktree
importlib.reload(sys.modules["src.projects"]) # подхватывает env ТЕКУЩЕГО процесса
from src.projects import known_plane_project_ids
```
- Деплоер по факту запускает скрипт **с хоста** (`.openclaw/agents/deployer.md`: `python3 scripts/staging_check.py --base-url http://localhost:8501`). В env хост-процесса `ORCH_PROJECTS_JSON` НЕ задан → `src.projects` грузит встроенный `_DEFAULT_PROJECTS` (ET + ORCH) → `known_plane_project_ids()` возвращает боевые id → **ложный FAIL**.
- Иными словами, B6 проверяет реестр НЕ того окружения, реестр которого реально использует staging-инстанс. Гипотеза деплоера про misconfig staging-контейнера — **опровергнута**.
## 2. Бизнес-цель
B6 должен достоверно отражать реестр проектов **именно работающего staging-инстанса** (изолированное окружение), а не реестр, восстановленный из локального импорта в произвольном process-env. При этом B6 обязан по-прежнему ловить реальное нарушение изоляции.
## 3. Заинтересованные стороны
| Роль | Интерес |
|------|---------|
| Deployer-агент | Достоверный сигнал staging-гейта; нет ложных откатов на development |
| Owner / прод | Изоляция staging от прод-проектов реально проверяется (не ложно-зелёная и не ложно-красная) |
| Self-hosting pipeline | `deploy-staging` — обязательная страховка перед прод-деплоем орка; ложный FAIL блокирует доставку всех ORCH-задач |
## 4. Объём (Scope)
### В объёме
- Исправление блока B6 в `scripts/staging_check.py`, чтобы он читал реестр в окружении staging-инстанса.
- Тест на корректность B6: оба исхода (PASS при чистой изоляции; FAIL при попадании прод-проекта в staging-реестр).
- Обновление документации B6 (`docs/operations/STAGING_CHECK.md`, при необходимости `docs/architecture/README.md`/CHANGELOG) в том же PR.
### Вне объёма (НЕ ТРОГАТЬ)
- `src/projects.py` — реестр работает корректно.
- `.env` / `.env.staging` — конфигурация верна.
- Прод-логика оркестратора.
- Остальные staging-чеки B1B5 и блок C E2E — зелёные.
## 5. Бизнес-требования
| ID | Требование |
|----|------------|
| BR-1 | B6 на staging даёт PASS (`sandbox=YES`, `prod-ET=NO`, `prod-ORCH=NO`), читая реестр из окружения staging-инстанса, а не из локального импорта хост-worktree. |
| BR-2 | B6 по-прежнему детектирует реальное нарушение изоляции: если бы прод-проект реально попал в staging-реестр, B6 обязан выдать FAIL. |
| BR-3 | Остальные staging-чеки не сломаны; `src/projects.py` и `.env*` не изменяются. |
| BR-4 | Существующие unit-тесты остаются зелёными (`pytest tests/ -q`). |
| BR-5 | Документация B6 обновлена в том же PR (golden source). |
## 6. Допущения и ограничения
- Решение должно быть минимально инвазивным и не затрагивать прод-логику.
- Скрипт `scripts/staging_check.py` использует только stdlib (нет `requests`/`httpx`) — это конвенция файла, её нужно сохранить.
- Способ запуска suite может варьироваться (с хоста / `docker exec` внутри контейнера) — выбранное решение должно быть корректным для канонического способа запуска деплоером и задокументировано.
## 7. Критерий успеха (бизнес)
- staging-прогон `scripts/staging_check.py` → **B6 PASS** при работающей изоляции.
- При искусственно нарушенной изоляции → **B6 FAIL** (проверяется тестом, без реального изменения staging).
- `python -m pytest tests/ -q` — зелёный.
## 8. Открытые вопросы (для архитектора)
Бизнес-запрос предлагает три варианта реализации (выбор за архитектором, см. 02-trz §4):
- (а) B6 читает реестр через HTTP-эндпоинт staging-инстанса;
- (б) B6 выполняет проверку через subprocess в окружении staging-контейнера (`docker exec`);
- (в) staging_check запускается ВНУТРИ staging-контейнера и читает собственный process-env (убрать host-path хак).
Предпочтение бизнес-запроса: минимально инвазивный вариант, не трогающий прод-логику.

View File

@@ -0,0 +1,118 @@
# 02 — Техническое задание (ТЗ / TRZ)
**Work Item:** ORCH-048
**Title:** staging B6 check reads registry from host worktree, not staging container
**Stage:** analysis
**Author:** analyst
**Date:** 2026-06-06
> Это ТЗ фиксирует требования и инварианты. Выбор одного из трёх архитектурных вариантов (§4) — за архитектором (ADR). Анализ варианты НЕ выбирает.
---
## 1. Задействованные модули
| Путь | Роль | Характер изменений |
|------|------|--------------------|
| `scripts/staging_check.py` | Suite живых staging-проверок; блок B6 (~строки 263284) | **Изменяется** — переписать механику получения реестра в B6 |
| `tests/` (новый файл, напр. `tests/test_staging_check_b6.py`) | Unit-тест корректности B6 | **Создаётся** |
| `docs/operations/STAGING_CHECK.md` | Док запуска suite | **Обновляется** (описание B6 + способ запуска) |
| `docs/architecture/README.md` / `CHANGELOG.md` | Golden source | **Обновляется** при необходимости |
### НЕ изменять (жёсткий инвариант scope)
- `src/projects.py` — реестр работает корректно.
- `.env`, `.env.staging`, `.env.example` — конфиг верен.
- Прод-логику оркестратора (`src/main.py` прод-роуты, `src/webhooks/*`, `src/stage_engine.py`, `src/qg/*`) — кроме случая варианта (а), если архитектор решит добавить read-only эндпоинт (см. §4а, отдельно обоснованный риск).
- Блоки A1A3, B4, B5 и блок C E2E в `staging_check.py`.
## 2. Текущее поведение (то, что чиним)
Блок B6 (`scripts/staging_check.py`):
```python
sys.path.insert(0, "/repos/orchestrator") # хост-worktree path
import importlib
if "src.projects" in sys.modules:
importlib.reload(sys.modules["src.projects"]) # перечитывает env ТЕКУЩЕГО процесса
from src.projects import known_plane_project_ids
known = known_plane_project_ids()
```
Проблема: реестр строится из `ORCH_PROJECTS_JSON` **process-env того процесса, в котором исполняется скрипт**. При запуске деплоером с хоста (`python3 scripts/staging_check.py --base-url http://localhost:8501`) переменная не задана → `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL. B6 не отражает реестр работающего staging-инстанса.
## 3. Требуемое поведение (контракт B6)
| ID | Требование |
|----|------------|
| TR-1 | B6 определяет набор «известных staging-инстансу Plane project id» из источника, который **гарантированно отражает окружение работающего staging-инстанса** (порт 8501 / контейнер `orchestrator-staging`), а не из локального импорта в process-env скрипта. |
| TR-2 | B6 PASS ⟺ `SANDBOX_PROJECT_ID ∈ known` И `PROD_ET_PROJECT_ID ∉ known` И `PROD_ORCH_PROJECT_ID ∉ known`. Идентификаторы — те же константы, что уже в скрипте. |
| TR-3 | B6 сохраняет формат вывода `Results.add(label, passed, detail)` с человекочитаемым detail (`sandbox=…, prod-ET=…, prod-ORCH=…`). |
| TR-4 | При недоступности источника реестра B6 даёт **детерминированный FAIL** с понятным detail (не падает с необработанным исключением, не даёт ложный PASS). |
| TR-5 | Скрипт остаётся на stdlib (без сторонних зависимостей), если выбранный вариант это допускает. |
| TR-6 | Удаляется зависимость B6 от хардкод-пути `/repos/orchestrator` для построения реестра (host-path хак), несовместимого с целью проверки. |
## 4. Варианты реализации — РЕШЕНИЕ ВЛАДЕЛЬЦА (обязательно)
> **РЕШЕНИЕ ПРИНЯТО ВЛАДЕЛЬЦЕМ ПРОЕКТА (Слава, 06.06): выбран ВАРИАНТ (в).**
> Архитектор НЕ выбирает заново — он фиксирует вариант (в) в ADR с обоснованием ниже.
>
> ### Почему (в), а НЕ (а) и НЕ (б)
> - **(а) HTTP-эндпоинт `GET /projects`** — ОТКЛОНЁН. Порождает «курицу-яйцо»: B6 ходит на эндпоинт **работающего** staging-инстанса, а эндпоинт запечён в Docker-образ → на первом прогоне его в живом инстансе ещё нет (404) → ложный FAIL → откат. Требует ручного bootstrap-деплоя. Это ровно тот класс поломки автономности, который мы устраняем. (Подтверждено на проде 06.06: `GET /projects` на 8501 → 404 → deploy-staging FAILED.)
> - **(б) `docker exec` subprocess** — ОТКЛОНЁН. Хрупкое экранирование (см. `docs/history/LESSONS_2026-06-05.md`), зависимость от docker-CLI и имени контейнера.
> - **(в) запуск suite ВНУТРИ staging-контейнера + чтение собственного process-env** — ВЫБРАН. B6 не зависит от того, что отдаёт инстанс по HTTP; `staging_check.py` берётся из mount (свежий код сразу, без ребилда образа); реестр читается из env самого `orchestrator-staging`. **Курицы-яйца нет ни на первом прогоне, ни в будущем.** Автономность не ломается.
>
> ### Что обязан зафиксировать архитектор в ADR (вариант в)
> 1. Убрать из B6 host-path хак `sys.path.insert(0, "/repos/orchestrator")` и `importlib.reload(src.projects)`.
> 2. Канонизировать запуск suite ВНУТРИ контейнера: `docker exec orchestrator-staging python3 <путь к staging_check.py> --base-url http://localhost:8501` (или эквивалент, где cwd/PYTHONPATH и env — staging-контейнера). Код импортируется из кода контейнера, env уже staging.
> 3. **Синхронно** обновить `.openclaw/agents/deployer.md` (способ запуска suite через `docker exec`, НЕ с хоста) и `docs/operations/STAGING_CHECK.md` — иначе host-запуск воспроизведёт баг.
> 4. Логику вердикта B6 вынести в чистую функцию `_evaluate_b6(known: set[str]) -> tuple[bool, str]` (TR-2/§9) для unit-теста на оба исхода (AC-2).
> 5. НЕ добавлять HTTP-эндпоинт `/projects` и НЕ трогать прод-`src/main.py`. НЕ трогать `src/projects.py`, `.env*`, прочие чеки A/B4/B5/C.
>
> ### Нюанс топологии (учесть)
> `Dockerfile` НЕ копирует `scripts/` в образ → `staging_check.py` доступен в контейнере только через mount `/repos/orchestrator/scripts/...`. Архитектор должен указать в ADR корректный путь запуска внутри контейнера, учитывая этот mount (а не `/app/scripts`).
---
## 4-original. Варианты реализации (исходный анализ — справочно)
## 4. Варианты реализации (выбор — архитектор, в ADR)
Бизнес-запрос предлагает три варианта. Анализ перечисляет их с известными плюсами/минусами; решение и обоснование — в `06-adr/`.
### (а) HTTP-эндпоинт staging-инстанса
B6 запрашивает реестр у работающего staging-инстанса по HTTP (как делают A/B4/B5/C).
- **Сейчас подходящего эндпоинта НЕТ.** `/health`, `/status`, `/queue` реестр проектов не отдают (`src/main.py`).
- Потребуется добавить read-only эндпоинт (напр. `GET /projects`, отдающий `known_plane_project_ids()` или список репо/prefix). Это касается прод-`main.py` → выходит за «не трогать прод-логику», но изменение read-only и низкорисковое — архитектор взвешивает.
- Плюс: B6 гарантированно читает реестр именно того процесса, что обслуживает webhooks. Единый HTTP-стиль с остальными чеками.
### (б) Subprocess в окружении staging-контейнера
B6 выполняет `docker exec orchestrator-staging python3 -c "from src.projects import known_plane_project_ids; ..."` и парсит stdout.
- Плюс: не трогает прод-`main.py`; читает env контейнера напрямую.
- Минус: требует доступности docker-CLI и имени контейнера из среды запуска suite; усложняет запуск «изнутри контейнера»; есть нюансы экранирования (см. `docs/history/LESSONS_2026-06-05.md`).
### (в) Запуск suite внутри контейнера + чтение собственного process-env
Канонизировать запуск `staging_check.py` ВНУТРИ `orchestrator-staging` (`docker exec orchestrator-staging python3 …`), убрать `sys.path.insert(0, "/repos/orchestrator")`, импортировать `src.projects` из кода контейнера (его cwd/PYTHONPATH), env уже staging.
- Плюс: минимально инвазивно, не трогает прод-логику и `src.projects`; согласуется с «рекомендуемым способом запуска» в `STAGING_CHECK.md §Способы запуска.1`.
- Условие: деплоер должен запускать suite через `docker exec` (а не с хоста). Нужно синхронно обновить `.openclaw/agents/deployer.md` и `STAGING_CHECK.md`, иначе host-запуск воспроизведёт баг.
- Нюанс: внутри контейнера код лежит в `/app` (Dockerfile `COPY`), а `/repos/orchestrator` — отдельный mount; импорт должен резолвиться из кода, чьим env реально живёт инстанс.
## 5. Изменения API
- Варианты (б) и (в): **нет** изменений API.
- Вариант (а): новый read-only эндпоинт (напр. `GET /projects`) — точная схема ответа определяется архитектором. Если выбран — задокументировать в `docs/architecture/README.md` (таблица API) и `CHANGELOG.md`.
## 6. Изменения схемы БД
Нет.
## 7. Требования к новым QG checks
Нет новых QG. Поведение `check_staging_status` (ADR-0003) не меняется — меняется только достоверность одного из чеков suite, чей агрегат пишется в `15-staging-log.md`.
## 8. Артефакты pipeline, создаваемые/обновляемые
- Код: `scripts/staging_check.py` (B6), новый тест в `tests/`.
- Док: `docs/operations/STAGING_CHECK.md`; при выборе варианта (а) — `docs/architecture/README.md` (API) и `CHANGELOG.md`; при выборе (в) — `.openclaw/agents/deployer.md` (способ запуска) и `STAGING_CHECK.md`.
- ADR: `docs/work-items/ORCH-048/06-adr/ADR-001-*.md` — обоснование выбранного варианта.
## 9. Тестируемость
- Логика «PASS/FAIL по набору known id» B6 должна быть выделена в чистую, юнит-тестируемую функцию (напр. `_evaluate_b6(known: set[str]) -> tuple[bool, str]`), чтобы тест проверял оба исхода без поднятия staging-инстанса/docker. План — `04-test-plan.yaml`.
## 10. Definition of Done
- BR-1…BR-5 (01-brd) выполнены.
- staging-прогон → B6 PASS; `pytest tests/ -q` зелёный.
- Док и (при необходимости) ADR обновлены в том же PR.

View File

@@ -0,0 +1,67 @@
# 03 — Критерии приёмки (Acceptance Criteria)
**Work Item:** ORCH-048
**Title:** staging B6 check reads registry from host worktree, not staging container
**Stage:** analysis
**Author:** analyst
**Date:** 2026-06-06
Каждый критерий формулирует чёткое условие PASS/FAIL. Источник — бизнес-запрос ORCH-048 (AC-1…AC-4) + BRD.
---
## AC-1 — B6 PASS на staging, читая реестр из staging-окружения
**Условие PASS:**
- При staging-прогоне `scripts/staging_check.py` (канонический способ запуска, выбранный архитектором) чек **B6** выдаёт `✓ PASS` c detail `sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)`.
- Набор known id, по которому судит B6, получен из окружения работающего staging-инстанса (HTTP-эндпоинт / docker-окружение контейнера / собственный process-env при запуске внутри контейнера), **не** из локального импорта `src.projects` в произвольном process-env с host-path хаком `/repos/orchestrator`.
**FAIL, если:** B6 даёт ложный FAIL (`prod-ET=YES(BAD!)` / `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции; либо реестр в B6 по-прежнему строится локальным импортом, зависящим от env процесса-запускателя.
## AC-2 — B6 ловит РЕАЛЬНОЕ нарушение изоляции (оба исхода покрыты тестом)
**Условие PASS:**
- Существует unit-тест, проверяющий логику вердикта B6 на **двух** входах:
1. «чистый» staging-реестр (`known = {SANDBOX}`) → B6 вердикт **PASS**;
2. «загрязнённый» реестр (например `known = {SANDBOX, PROD_ET}` и/или `{SANDBOX, PROD_ORCH}`) → B6 вердикт **FAIL**.
- Тест не требует поднятия живого staging-инстанса/docker (логика вердикта изолирована и тестируема, см. 02-trz §9).
**FAIL, если:** покрыт только один исход; либо B6 даёт PASS при наличии прод-проекта в реестре (потеря защитной функции).
## AC-3 — Остальные staging-чеки не сломаны; src/projects.py и .env не тронуты
**Условие PASS:**
- Блоки A1A3, B4, B5 и блок C (E2E) в `scripts/staging_check.py` функционально не изменены (формат вывода и логика прежние).
- `git diff` work item НЕ содержит изменений в `src/projects.py`, `.env`, `.env.staging`, `.env.example`.
- Прод-логика оркестратора не затронута. Исключение допускается только если архитектор в ADR выбрал вариант (а) и добавил read-only эндпоинт — тогда изменение ограничено добавлением этого эндпоинта, прод-поведение существующих роутов неизменно.
**FAIL, если:** изменён `src/projects.py` или любой `.env*`; либо затронута/сломана логика прочих чеков.
## AC-4 — Существующие unit-тесты зелёные
**Условие PASS:**
- `python -m pytest tests/ -q` завершается с кодом 0; все ранее зелёные тесты остаются зелёными; новый тест B6 (AC-2) проходит.
**FAIL, если:** любой тест падает.
## AC-5 — Документация обновлена в том же PR (golden source)
**Условие PASS:**
- `docs/operations/STAGING_CHECK.md` отражает исправленную механику B6 и канонический способ запуска suite.
- При выборе варианта (а): обновлены таблица API в `docs/architecture/README.md` и `CHANGELOG.md`.
- При выборе варианта (в): обновлены `.openclaw/agents/deployer.md` (запуск через `docker exec`) и `STAGING_CHECK.md`.
- Заведён ADR `docs/work-items/ORCH-048/06-adr/ADR-001-*.md` с обоснованием выбранного варианта.
**FAIL, если:** код изменён, а соответствующая док/ADR не обновлены.
---
## Сводная проверка (как мерить приёмку)
| AC | Команда / действие | Ожидаемый результат |
|----|--------------------|---------------------|
| AC-1 | staging-прогон suite (выбранным способом) | `B6 … ✓ PASS [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]` |
| AC-2 | `pytest tests/test_staging_check_b6.py -q` | оба кейса (clean→PASS, polluted→FAIL) зелёные |
| AC-3 | `git diff --name-only` по ветке | нет `src/projects.py`, нет `.env*`; чеки A/B4/B5/C не изменены по сути |
| AC-4 | `python -m pytest tests/ -q` | exit 0, все PASS |
| AC-5 | ревью diff документации | STAGING_CHECK.md + ADR-001 присутствуют и согласованы с кодом |

View File

@@ -0,0 +1,97 @@
work_item: ORCH-048
title: staging B6 check reads registry from host worktree, not staging container
stage: analysis
notes: >
B6 в staging_check.py должен оценивать реестр окружения работающего staging-инстанса.
Для тестируемости логика вердикта B6 выделяется в чистую функцию (напр.
_evaluate_b6(known: set[str]) -> tuple[bool, str]); тесты бьют именно её и не
поднимают живой staging-инстанс/docker. Идентификаторы — те же константы из скрипта:
SANDBOX_PROJECT_ID=8c5a3025-4f9d-4190-b79f-fa06276bb27e,
PROD_ET_PROJECT_ID=7a79f0a9-5278-49cd-9007-9a338f238f9c,
PROD_ORCH_PROJECT_ID=8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a.
tests:
- id: TC-01
type: unit
description: >
B6-вердикт PASS при чистом staging-реестре: known={SANDBOX} ->
passed=True, detail содержит sandbox=YES, prod-ET=NO, prod-ORCH=NO. (AC-1, AC-2)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-02
type: unit
description: >
B6-вердикт FAIL при попадании прод-ET в реестр: known={SANDBOX, PROD_ET} ->
passed=False, detail помечает prod-ET как нарушение. (AC-2)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-03
type: unit
description: >
B6-вердикт FAIL при попадании прод-ORCH в реестр: known={SANDBOX, PROD_ORCH} ->
passed=False, detail помечает prod-ORCH как нарушение. (AC-2)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-04
type: unit
description: >
B6-вердикт FAIL при отсутствии sandbox в реестре: known=set() (пусто) ->
passed=False (sandbox absent), детерминированно, без исключения. (AC-2, TR-4)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-05
type: unit
description: >
B6-вердикт FAIL при загрязнении и ET, и ORCH одновременно:
known={SANDBOX, PROD_ET, PROD_ORCH} -> passed=False. (AC-2)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-06
type: unit
description: >
Источник реестра в B6 больше не зависит от host-path хака
sys.path.insert(0,"/repos/orchestrator"): проверить (статически/через структуру
кода или мок источника), что построение known не делается локальным импортом
src.projects из произвольного process-env. (AC-1, TR-6)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-07
type: unit
description: >
Деградация источника реестра (HTTP-ошибка / недоступный контейнер / битый ответ)
-> B6 даёт детерминированный FAIL с понятным detail, а не ложный PASS и не
необработанное исключение. (TR-4)
module: tests/test_staging_check_b6.py
expected: PASS
- id: TC-08
type: unit
description: >
Регрессия реестра: существующие тесты src/projects.py остаются зелёными,
подтверждая, что src/projects.py не изменён. (AC-3, AC-4)
module: tests/test_projects.py
expected: PASS
- id: TC-09
type: integration
description: >
Полный прогон pytest без падений после правок:
`python -m pytest tests/ -q` -> exit 0. (AC-4)
module: tests/
expected: PASS
- id: TC-10
type: integration
description: >
Живой staging-прогон (ручной, вне CI): запустить scripts/staging_check.py
выбранным архитектором способом против orchestrator-staging (8501) ->
B6 == PASS (sandbox=YES, prod-ET=NO, prod-ORCH=NO); блоки A/B4/B5/C не сломаны.
(AC-1, AC-3) Выполняется деплоером на стадии deploy-staging.
module: scripts/staging_check.py
expected: PASS

View File

@@ -0,0 +1,139 @@
# ADR-001: B6 читает реестр через запуск suite ВНУТРИ staging-контейнера
## Статус
Accepted
- **Задача:** ORCH-048
- **Дата:** 2026-06-06
- **Автор:** architect
- **Решение варианта:** принято Владельцем проекта (Слава, 06.06) — вариант **(в)**. Архитектор фиксирует и обосновывает.
## Контекст
Чек **B6 «Registry: sandbox present, prod ET/ORCH absent»** в `scripts/staging_check.py`
(блок B, ~строки 263284) — страховка изоляции staging: подтверждает, что в реестре
проектов работающего staging-инстанса есть только sandbox-проект и НЕТ боевых
(enduro-trails / orchestrator).
B6 даёт **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`), хотя изоляция
реестра в staging исправна. Root cause (подтверждён прямым запуском, 06.06):
```python
sys.path.insert(0, "/repos/orchestrator") # host-worktree path
import importlib
if "src.projects" in sys.modules:
importlib.reload(sys.modules["src.projects"]) # перечитывает env ТЕКУЩЕГО процесса
from src.projects import known_plane_project_ids
known = known_plane_project_ids()
```
B6 — единственный чек, который не ходит к инстансу по HTTP, а импортирует Python-код
локально и строит реестр из `ORCH_PROJECTS_JSON` **process-env того процесса, в котором
исполняется скрипт**. Деплоер фактически запускает suite **с хоста**
(`python3 scripts/staging_check.py --base-url http://localhost:8501`), где
`ORCH_PROJECTS_JSON` не задан → `src.projects` грузит встроенный `_DEFAULT_PROJECTS`
(ET + ORCH) → ложный FAIL. B6 проверяет реестр НЕ того окружения, реестр которого
реально использует staging-инстанс.
### Топология (ключевой факт для решения)
- Контейнер `orchestrator-staging`: `WORKDIR /app`, `ENV PYTHONPATH=/app`; код приложения
**скопирован** в образ (`Dockerfile: COPY src/ ./src/`) → живёт в `/app/src/`.
- `.env.staging` (env_file контейнера) задаёт `ORCH_PROJECTS_JSON` = только sandbox.
- `Dockerfile` **НЕ копирует** `scripts/` в образ. Скрипт доступен в контейнере только
через bind-mount `/home/slin/repos:/repos``/repos/orchestrator/scripts/staging_check.py`.
Из этого следует: при запуске `docker exec orchestrator-staging python3
/repos/orchestrator/scripts/staging_check.py` интерпретатор добавляет в `sys.path[0]`
каталог скрипта (`/repos/orchestrator/scripts`), а `import src.projects` резолвится через
`PYTHONPATH=/app``/app/src/projects.py` (собственный код контейнера) с env из
`.env.staging`. Это ровно реестр работающего staging-инстанса — без HTTP, без host-path хака.
## Решение
Принят **вариант (в): канонизировать запуск suite ВНУТРИ `orchestrator-staging` и читать
собственный process-env контейнера.**
Архитектурно фиксируется (детальная реализация — стадия development):
1. **Убрать из B6 host-path хак:** удалить `sys.path.insert(0, "/repos/orchestrator")` и
`importlib.reload(sys.modules["src.projects"])`. Импорт `from src.projects import
known_plane_project_ids` остаётся, но резолвится из кода контейнера (`/app/src` через
`PYTHONPATH=/app`), env которого — staging (`.env.staging`).
2. **Канонизировать запуск suite внутри контейнера** (а не с хоста):
```bash
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
`--base-url http://localhost:8501` корректен изнутри контейнера: сеть `network_mode: host`.
Путь к скрипту — `/repos/orchestrator/scripts/...` (mount), а НЕ `/app/scripts` (в образе
scripts отсутствует).
3. **Синхронно обновить документацию запуска** (этот же PR), иначе host-запуск воспроизведёт
баг:
- `.openclaw/agents/deployer.md` — команда стадии `deploy-staging` через `docker exec`.
- `docs/operations/STAGING_CHECK.md` — канонический способ запуска и описание механики B6.
4. **Логику вердикта B6 вынести в чистую функцию** `_evaluate_b6(known: set[str]) ->
tuple[bool, str]`, инвариант (TR-2): `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧
PROD_ORCH ∉ known`; `detail` сохраняет формат `sandbox=…, prod-ET=…, prod-ORCH=…` (TR-3).
Функция юнит-тестируема без поднятия инстанса/docker (TC-01…TC-07).
5. **Детерминированная деградация (TR-4):** при недоступности источника реестра (ошибка
импорта/построения `known`) B6 даёт FAIL с понятным detail, без необработанного исключения
и без ложного PASS.
### Границы (scope guards — обязательны)
- **НЕ** добавлять HTTP-эндпоинт `GET /projects`; **НЕ** трогать прод-`src/main.py`,
`src/webhooks/*`, `src/stage_engine.py`, `src/qg/*`.
- **НЕ** изменять `src/projects.py`, `.env`, `.env.staging`, `.env.example`.
- **НЕ** менять блоки A1A3, B4, B5 и блок C (E2E): формат вывода и логика прежние.
- Реестр QG и стадий не меняется; ADR-0003 (`check_staging_status`) в силе — меняется только
достоверность одного чека внутри suite, чей агрегат пишется в `15-staging-log.md`.
## Альтернативы (отклонены)
### (а) HTTP-эндпоинт `GET /projects` работающего staging-инстанса — ОТКЛОНЁН
Порождает «курицу-яйцо»: B6 ходит на эндпоинт **работающего** инстанса, а эндпоинт запечён
в Docker-образ → на первом прогоне его в живом инстансе ещё нет (404) → ложный FAIL → откат.
Требует ручного bootstrap-деплоя. Это ровно тот класс поломки автономности, который мы
устраняем. Подтверждено на проде 06.06: `GET /projects` на 8501 → 404 → deploy-staging FAILED.
(Предыдущая итерация архитектора выбрала (а); решение отклонено Владельцем, код и ADR(а)
удалены, ветка откатана к analyst-артефактам.)
### (б) `docker exec` subprocess + парсинг stdout — ОТКЛОНЁН
`docker exec orchestrator-staging python3 -c "..."` из процесса suite. Хрупкое экранирование
(`docs/history/LESSONS_2026-06-05.md`), зависимость от наличия docker-CLI и имени контейнера
в среде запуска, усложняет запуск «изнутри контейнера».
### (в) Запуск suite внутри контейнера + собственный process-env — ВЫБРАН
B6 не зависит от того, что отдаёт инстанс по HTTP; `staging_check.py` берётся из mount (свежий
код сразу, без ребилда образа); реестр читается из env самого `orchestrator-staging`. Курицы-яйца
нет ни на первом прогоне, ни в будущем. Минимально инвазивно, прод-логика и `src/projects.py` не
тронуты. Согласуется с «рекомендуемым способом запуска» (`STAGING_CHECK.md §Способы запуска.1`).
## Последствия
**Плюсы**
- B6 достоверно отражает реестр работающего staging-инстанса; ложные FAIL/откаты устранены.
- Автономность self-hosting не ломается: нет bootstrap-зависимости от запечённого в образ кода.
- Свежий `staging_check.py` подхватывается из mount без ребилда образа.
- Защитная функция B6 сохранена и покрыта юнит-тестами на оба исхода (PASS/FAIL).
**Минусы / ограничения**
- Запуск suite **обязан** идти через `docker exec` внутри `orchestrator-staging`. Запуск с
хоста воспроизведёт исходный баг (host-env без `ORCH_PROJECTS_JSON`). Это закреплено в
`deployer.md` и `STAGING_CHECK.md`; способ «с хоста» остаётся возможен, только если env
хоста корректно повторяет staging (не рекомендуется, помечено).
- Деплоер должен иметь доступ к docker-CLI/сокету (есть: `/var/run/docker.sock` смонтирован в
контейнер оркестратора, у которого deployer-агент исполняется; `deployer.md` tools: Bash docker).
## Связи
- ADR-0003 (`docs/architecture/adr/adr-0003-staging-gate.md`) — staging-гейт, который этот чек
обслуживает.
- ORCH-6 / `src/projects.py` — реестр проектов (источник `known_plane_project_ids()`),
НЕ изменяется.
- `docs/history/LESSONS_2026-06-05.md` — обоснование отказа от варианта (б).

View File

@@ -0,0 +1,69 @@
---
type: review
work_item_id: ORCH-048
verdict: APPROVED
version: 1
---
# Review ORCH-048
## Summary
PR чинит ложный FAIL чека **B6** в `scripts/staging_check.py`: реестр проектов теперь
читается из окружения работающего staging-инстанса (вариант «в», выбранный Владельцем и
зафиксированный в ADR-001), host-path хак `sys.path.insert(0, "/repos/orchestrator")` +
`importlib.reload` удалён. Реализация соответствует ТЗ, ADR-001 и всем критериям приёмки.
Документация обновлена синхронно. `pytest tests/ -q`**470 passed**.
Соответствие осям проверки:
- **ТЗ (02-trz):** TR-1…TR-6 выполнены. TR-1/TR-6 — реестр строится из process-env
инстанса, host-path хак удалён. TR-2 — инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉
known ∧ PROD_ORCH ∉ known` в `_evaluate_b6`. TR-3 — формат detail сохранён. TR-4 —
детерминированный FAIL при недоступности источника (`_run_b6` ловит `Exception`, нет
ложного PASS, нет необработанного исключения). TR-5 — stdlib. §9 — логика вердикта
вынесена в чистую `_evaluate_b6` для unit-теста.
- **ADR-001:** реализация дословно следует пунктам 15 решения и scope-guards.
HTTP-эндпоинт не добавлен, прод-`src/main.py` не тронут.
- **AC-1…AC-5:** AC-1 — механика читает реестр инстанса; AC-2 — оба исхода покрыты
(TC-01 clean→PASS, TC-02/03/05 polluted→FAIL); AC-3 — `git diff` не содержит
`src/projects.py`/`.env*`, блоки A1A3/B4/B5/C не тронуты; AC-4 — 470 passed; AC-5 —
STAGING_CHECK.md, deployer.md, CHANGELOG, ADR-001 обновлены в этом же PR.
- **Качество кода:** чистые функции, докстринги на всех новых функциях, defensive-обработка,
`sys` остаётся используемым (`sys.exit`), без мёртвых импортов. Тесты содержательные
(7 TC + happy-path wiring + статическая проверка отсутствия хака).
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- [ ] `test_tc06_no_host_path_hack_in_source` и `test_tc06_registry_loader_uses_src_projects`
носят одинаковый префикс `tc06` — формально это два разных кейса; имена можно было бы
развести для читаемости отчёта pytest. Косметика, на приёмку не влияет.
## Документация
Полностью обновлена в том же PR (golden source соблюдён):
- `docs/operations/STAGING_CHECK.md` — канонический способ запуска (способ 1 через
`docker exec`), способ «с хоста» помечен как невалидный/воспроизводящий баг, добавлена
секция «Механика чека B6».
- `.openclaw/agents/deployer.md` — команда стадии `deploy-staging` переведена на
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py …`
с пояснением, почему host-запуск ломает B6.
- `CHANGELOG.md` — запись в разделе Fixed с полным описанием root cause и решения.
- ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`
обоснование варианта (в), отклонённые (а)/(б), scope-guards.
`docs/architecture/README.md` обновлять не требовалось: API, реестр стадий и `QG_CHECKS`
не менялись (изменение касается только достоверности одного чека внутри suite).
**Вердикт: APPROVED** — P0/P1 отсутствуют.

View File

@@ -0,0 +1,79 @@
---
type: test-report
work_item_id: ORCH-048
result: PASS
---
# Test Report — ORCH-048
**Title:** staging B6 check reads registry from host worktree, not staging container
**Stage:** testing
**Branch:** feature/ORCH-048-staging-b6-check-reads-registr
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-06T07:06Z
- Prod API (8500): `/health` 200 ok, `/status` 200 (ORCH-048 в stage=testing), `/queue` 200 (breaker closed, preflight ok)
## Результаты
| TC ID | Тип | Описание | Результат |
|-------|-----|----------|-----------|
| TC-01 | unit | `known={SANDBOX}` → B6 PASS, detail sandbox=YES/prod-ET=NO/prod-ORCH=NO | PASS |
| TC-02 | unit | `known={SANDBOX,PROD_ET}` → B6 FAIL, prod-ET помечен нарушением | PASS |
| TC-03 | unit | `known={SANDBOX,PROD_ORCH}` → B6 FAIL, prod-ORCH помечен нарушением | PASS |
| TC-04 | unit | `known=set()` (нет sandbox) → детерминированный FAIL без исключения | PASS |
| TC-05 | unit | `known={SANDBOX,PROD_ET,PROD_ORCH}` → B6 FAIL | PASS |
| TC-06 | unit | Нет host-path хака `/repos/orchestrator`; реестр строится не локальным импортом в произвольном process-env | PASS |
| TC-07 | unit | Деградация источника реестра → детерминированный FAIL с понятным detail (не ложный PASS, не необработанное исключение) | PASS |
| TC-08 | unit | Регрессия `src/projects.py` (16 тестов) зелёные — реестр не изменён | PASS |
| TC-09 | integration | `python -m pytest tests/ -q` → exit 0 | PASS |
| TC-10 | integration | Живой staging-прогон B6 на 8501 | DEFERRED — выполняется деплоером на стадии deploy-staging (см. 04-test-plan TC-10) |
Доп. покрытие: `test_run_b6_records_pass_for_clean_registry` (happy-path wiring `_run_b6`).
## Покрытие критериев приёмки
| AC | Подтверждение | Статус |
|----|---------------|--------|
| AC-1 | B6 PASS на чистом реестре (TC-01), источник — окружение инстанса, host-path хак удалён (TC-06) | PASS |
| AC-2 | Оба исхода покрыты: clean→PASS (TC-01), polluted→FAIL (TC-02/03/05), без sandbox→FAIL (TC-04) | PASS |
| AC-3 | `git diff origin/main...HEAD` НЕ содержит `src/projects.py` / `.env*`; блоки A/B4/B5/C не тронуты | PASS |
| AC-4 | `pytest tests/ -q` → exit 0, 470 passed | PASS |
| AC-5 | STAGING_CHECK.md, deployer.md, CHANGELOG.md, ADR-001 обновлены в том же PR (подтверждено review) | PASS |
## Проверка scope (AC-3)
Изменённые файлы ветки vs origin/main:
```
.openclaw/agents/deployer.md
CHANGELOG.md
docs/operations/STAGING_CHECK.md
docs/work-items/ORCH-048/* (артефакты задачи)
scripts/staging_check.py
tests/test_staging_check_b6.py
```
Forbidden-path check: OK — `src/projects.py` и `.env*` НЕ затронуты.
## Вывод pytest
Полный прогон:
```
470 passed, 1 warning in 9.07s
pytest exit code: 0
```
(warning — PydanticDeprecatedSince20 в `src/config.py`, предсуществующий, к ORCH-048 не относится.)
B6 suite:
```
tests/test_staging_check_b6.py ......... [9 passed]
9 passed, 1 warning in 0.29s
```
projects-регрессия (TC-08):
```
16 passed, 1 warning in 0.31s
```
## Итог
**PASS** — все unit/integration тесты зелёные (470 passed, exit 0), smoke API prod исправен, scope-инвариант AC-3 соблюдён. Живой staging-прогон B6 (TC-10) выполняется деплоером на стадии deploy-staging.

View File

@@ -0,0 +1,42 @@
---
deploy_status: SUCCESS
timestamp: 2026-06-06T07:12:00Z
work_item: ORCH-048
target: orchestrator (prod, self-hosting)
restart_required: false
rebuild_required: false
staging_gate: SUCCESS
---
# Production Deploy Log — ORCH-048
Production rollout of ORCH-048 (`fix(staging_check): B6 reads registry from running staging instance env`) is **complete on merge** — no prod container rebuild or restart was required, and none was performed.
## Precondition: staging gate
`deploy` is reachable only because the staging gate (`deploy-staging`) passed:
- `15-staging-log.md``staging_status: SUCCESS`, **10/10 checks PASS** (incl. B6 registry-isolation, the fix's target), run inside `orchestrator-staging` (8501).
## Change scope (why no restart)
The work item changes touch **only bind-mounted / non-runtime artifacts**`src/` (the code running in the prod container) and the `Dockerfile` are untouched:
| File | Kind | How it reaches prod |
|------|------|---------------------|
| `scripts/staging_check.py` | bind-mounted (`/home/slin/repos``/repos`); **not** copied into the image (`scripts/` absent from `/app`) | host `git pull` → live immediately |
| `.openclaw/agents/deployer.md` | bind-mounted agent prompt, read at agent launch | host `git pull` → live on next agent run |
| `CHANGELOG.md`, `docs/operations/STAGING_CHECK.md` | docs | n/a |
| `tests/test_staging_check_b6.py` | test, not deployed | n/a |
Because nothing in `src/` or the image changed, there is **no container rebuild and no restart** for the shared prod `orchestrator` (8500). Per CLAUDE.md / INFRA.md self-hosting rules, the prod container that serves all projects (enduro-trails + orchestrator) was **not** touched — zero group-risk.
## Deploy action
- **Prod container restart/rebuild:** not required, not performed (guardrail: never restart prod `orchestrator` within an ORCH task).
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): not triggered by this agent (not explicitly instructed; reserved for Owner per ORCH-36).
- **Effective rollout:** merge of this branch to `main` + routine host `git pull` makes the corrected `staging_check.py` and `deployer.md` live; the prod app process is unaffected.
## Verdict
`deploy_status: SUCCESS` — staging gate green, change is bind-mount-only, prod instance untouched, no rollback needed.

View File

@@ -0,0 +1,50 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T07:08:59Z
base_url: http://localhost:8501
work_item: ORCH-048
mode: stub
result: 10/10 checks PASS
---
# Staging Gate Log — ORCH-048
Staging test suite completed against the live `orchestrator-staging` instance (port 8501). **All 10/10 checks passed.**
## Execution context
- **Where**: inside the `orchestrator-staging` container via Docker Engine API exec (canonical per ORCH-048 / ADR-001; `docker` CLI not present in this agent env, so the bind-mounted socket `/var/run/docker.sock` was used directly).
- **Command**: `python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
- **Exit code**: `0`
- **Container state**: `orchestrator-staging` running (Up 25 hours).
Running inside the container is required so the B6 registry-isolation check reads the registry from the running instance's own process-env (`.env.staging``ORCH_PROJECTS_JSON` = sandbox-only). This is precisely the behaviour ORCH-048 corrects.
## Results
```
[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 sandbox branch, Plane issue, and DB rows
============================================================
RESULT: 10/10 checks PASS
============================================================
```
**B6 verdict (the ORCH-048 target check): PASS** — registry read from the running staging instance correctly shows sandbox present and prod ET/ORCH absent, with no false FAIL / spurious rollback.

13
pytest.ini Normal file
View File

@@ -0,0 +1,13 @@
[pytest]
# ORCH-39: make the async webhook/state tests (test_orch10_states.py) actually
# run in every environment. Without pytest-asyncio + asyncio_mode=auto these
# @pytest.mark.asyncio tests were silently SKIPPED, so a broken async path
# could pass CI. asyncio_mode=auto runs `async def test_*` natively.
asyncio_mode = auto
# Fail loudly on unknown markers so a typo'd @pytest.mark.* can't silently
# disable a test.
markers =
asyncio: mark a coroutine test to be run by pytest-asyncio.
testpaths = tests

View File

@@ -3,3 +3,4 @@ uvicorn[standard]==0.30.0
pydantic-settings==2.5.0
httpx==0.27.0
pytest==8.3.3
pytest-asyncio==0.23.8

View File

@@ -0,0 +1,176 @@
#!/bin/bash
# Deploy hook for orchestrator
# Supports --deploy (default) and --rollback modes.
# Adds health-check loop + automatic rollback if new deploy is unhealthy.
#
# Parametrised via env vars (defaults are STAGING — never prod):
# TARGET_SERVICE - docker-compose service name (default: orchestrator-staging)
# TARGET_PORT - health check port (default: 8501)
# 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)
# 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
set -euo pipefail
REPO=/home/slin/repos/orchestrator
# ---- Defaults (STAGING — safe) ---------------------------------------------
TARGET_SERVICE="${TARGET_SERVICE:-orchestrator-staging}"
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}"
# ---- Log setup -------------------------------------------------------------
LOG_DIR=/var/log/orchestrator
if mkdir -p "$LOG_DIR" 2>/dev/null; then
LOG="${LOG:-$LOG_DIR/deploy-hook.log}"
else
LOG="${LOG:-$REPO/deploy-hook.log}"
fi
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"
}
log "Deploy hook called: target=$TARGET_SERVICE port=$TARGET_PORT args=$*"
cd "$REPO"
# ============================================================================
# HEALTH CHECK helper
# Args: max_attempts sleep_sec label
# Returns 0 if healthy within attempts, 1 otherwise
# ============================================================================
health_check() {
local max_attempts="$1"
local sleep_sec="$2"
local label="${3:-health-check}"
local attempt=0
while [[ $attempt -lt $max_attempts ]]; do
attempt=$(( attempt + 1 ))
log "$label: attempt $attempt/$max_attempts - GET http://localhost:$TARGET_PORT/health"
local http_code body
body=$(curl -s --max-time 5 "http://localhost:$TARGET_PORT/health" 2>/dev/null || true)
http_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://localhost:$TARGET_PORT/health" 2>/dev/null || echo "000")
if [[ "$http_code" == "200" ]] && echo "$body" | grep -q '"status":"ok"'; then
log "$label: OK (HTTP $http_code, body=$body)"
return 0
fi
log "$label: not ready yet (HTTP $http_code, body=$body)"
if [[ $attempt -lt $max_attempts ]]; then
sleep "$sleep_sec"
fi
done
log "$label: FAILED after $max_attempts attempts"
return 1
}
# ============================================================================
# ROLLBACK helper (also called for auto-rollback after bad deploy)
# ============================================================================
do_rollback() {
log "ROLLBACK: checking $PREV_IMAGE_FILE"
if [[ ! -s "$PREV_IMAGE_FILE" ]]; then
log "ROLLBACK: no previous image recorded - rollback skipped (exit 1)"
return 1
fi
local prev_img
prev_img=$(cat "$PREV_IMAGE_FILE")
if [[ -z "$prev_img" ]]; then
log "ROLLBACK: PREV_IMAGE_FILE is empty - rollback skipped (exit 1)"
return 1
fi
if ! docker image inspect "$prev_img" >/dev/null 2>&1; then
log "ROLLBACK: recorded image '$prev_img' not found locally - rollback skipped (exit 1)"
return 1
fi
log "ROLLBACK: retagging $prev_img -> $TARGET_IMAGE"
docker tag "$prev_img" "$TARGET_IMAGE" >> "$LOG" 2>&1
log "ROLLBACK: restarting $TARGET_SERVICE on previous 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 "ROLLBACK: container restarted, running post-rollback health check (5x3s)"
if health_check 5 3 "ROLLBACK-health"; then
log "ROLLBACK: service is healthy on previous image ($prev_img)"
return 0
else
log "ROLLBACK: ROLLBACK ALSO FAILED - service still unhealthy after restoring $prev_img"
return 2
fi
}
# ============================================================================
# MANUAL --rollback mode
# ============================================================================
if [[ "${1:-}" == "--rollback" ]]; then
log "Manual ROLLBACK requested"
if do_rollback; then
log "Manual ROLLBACK succeeded"
exit 0
else
log "Manual ROLLBACK failed"
exit 1
fi
fi
# ============================================================================
# NORMAL DEPLOY mode (--deploy or no argument)
# ============================================================================
# 1. Capture currently running image BEFORE restart (best-effort)
PREV_IMG=""
SVC_CID=$(docker compose --profile "$COMPOSE_PROFILE" ps -q "$TARGET_SERVICE" 2>/dev/null || true)
if [[ -n "$SVC_CID" ]]; then
PREV_IMG=$(docker inspect --format '{{.Image}}' "$SVC_CID" 2>/dev/null || true)
fi
if [[ -n "$PREV_IMG" ]]; then
echo "$PREV_IMG" > "$PREV_IMAGE_FILE"
log "Saved previous image: $PREV_IMG -> $PREV_IMAGE_FILE"
else
log "No previous image captured (first deploy or service not running?)"
fi
# 2. Pull latest code
log "git pull origin main"
git pull origin main >> "$LOG" 2>&1
# 3. Restart service
log "Starting $TARGET_SERVICE (profile=$COMPOSE_PROFILE)"
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 "$TARGET_SERVICE restarted"
# 4. Health-check loop: 10 attempts x 6 seconds = up to 60s
log "Starting health-check: 10 attempts x 6s (max 60s)"
if health_check 10 6 "deploy-health"; then
log "Deploy SUCCESS: $TARGET_SERVICE healthy on port $TARGET_PORT"
exit 0
fi
# 5. Health failed -> AUTO ROLLBACK
log "deploy FAILED: health not ok after 60s - initiating AUTO ROLLBACK"
rollback_rc=0
do_rollback || rollback_rc=$?
if [[ $rollback_rc -eq 0 ]]; then
log "deploy FAILED, rolled back to previous image successfully - exit 1"
exit 1
elif [[ $rollback_rc -eq 2 ]]; then
log "deploy FAILED, ROLLBACK ALSO FAILED - service may be down - exit 2"
exit 2
else
log "deploy FAILED, rollback skipped (no previous image) - exit 1"
exit 1
fi

681
scripts/staging_check.py Normal file
View File

@@ -0,0 +1,681 @@
#!/usr/bin/env python3
"""
staging_check.py — Live staging-stand health & e2e check suite (ORCH-33).
Checks:
Block A — SMOKE (health/queue, correct env)
Block B — ACCESS (read-only calls to Plane sandbox + Gitea sandbox + registry)
Block C — E2E (create task in SANDBOX → trigger pipeline via /webhook/plane
→ verify branch + job enqueued → CLEANUP in finally)
Usage — CANONICAL: run INSIDE the orchestrator-staging container (ORCH-048, ADR-001)
so B6 reads the registry from the running instance's own env (.env.staging):
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 [--mode stub|full-real]
Running from the host leaves ORCH_PROJECTS_JSON unset → B6 falls back to the
default (ET+ORCH) registry → false FAIL. See docs/operations/STAGING_CHECK.md.
Exit code: 0 = all PASS, non-zero = at least one FAIL.
NOTE on modes:
stub — default; checks early pipeline artifacts (branch + analyst job
enqueued) created BEFORE Claude CLI is invoked.
Fast, deterministic, no LLM spend.
full-real — additionally waits for the analyst agent to finish (long, costs
credits). Not the default.
NOTE on Plane comments (403):
The orchestrator posts the "🔍 Analyst запущен" comment using per-agent bot
tokens (ORCH_PLANE_BOT_ANALYST). These bot accounts must be added as members
of every Plane project they comment on. In staging the sandbox project was
created after the bots were provisioned → the bots are not yet members of
SANDBOX → add_comment returns 403 Forbidden.
This is a known infrastructure limitation of the staging sandbox, NOT a bug
in the pipeline itself. C9b therefore verifies pipeline success via the
staging job queue (/queue → recent) instead of Plane comments: the analyst
job is enqueued BEFORE the add_comment call and its presence in the queue
proves the pipeline ran through correctly.
"""
import argparse
import hashlib
import hmac
import json
import os
import sys
import time
import datetime
import urllib.request
import urllib.error
import urllib.parse
# ---------------------------------------------------------------------------
# Colour helpers
# ---------------------------------------------------------------------------
_BOLD = "\033[1m"
_GREEN = "\033[32m"
_RED = "\033[31m"
_YELLOW = "\033[33m"
_RESET = "\033[0m"
def _ok(msg: str) -> str:
return f" {_GREEN}✓ PASS{_RESET} {msg}"
def _fail(msg: str) -> str:
return f" {_RED}✗ FAIL{_RESET} {msg}"
def _info(msg: str) -> str:
return f" {_YELLOW}·{_RESET} {msg}"
# ---------------------------------------------------------------------------
# Low-level HTTP helpers (stdlib only — no requests/httpx in scripts/)
# ---------------------------------------------------------------------------
def _http(method: str, url: str, headers: dict | None = None,
body: bytes | None = None, timeout: int = 15) -> tuple[int, bytes]:
"""Simple HTTP wrapper. Returns (status_code, response_body)."""
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e:
raise RuntimeError(f"{method} {url}{e}") from e
def _get(url: str, headers: dict | None = None, timeout: int = 15) -> tuple[int, dict]:
status, body = _http("GET", url, headers=headers, timeout=timeout)
try:
data = json.loads(body)
except Exception:
data = {"_raw": body.decode(errors="replace")}
return status, data
def _post(url: str, headers: dict | None = None, payload: dict | None = None,
raw_body: bytes | None = None, timeout: int = 15) -> tuple[int, dict]:
if raw_body is not None:
body = raw_body
h = dict(headers or {})
if "Content-Type" not in h:
h["Content-Type"] = "application/json"
else:
body = json.dumps(payload or {}).encode()
h = dict(headers or {})
h["Content-Type"] = "application/json"
status, resp_body = _http("POST", url, headers=h, body=body, timeout=timeout)
try:
data = json.loads(resp_body)
except Exception:
data = {"_raw": resp_body.decode(errors="replace")}
return status, data
def _patch(url: str, headers: dict | None = None, payload: dict | None = None,
timeout: int = 15) -> tuple[int, dict]:
body = json.dumps(payload or {}).encode()
h = dict(headers or {})
h["Content-Type"] = "application/json"
status, resp_body = _http("PATCH", url, headers=h, body=body, timeout=timeout)
try:
data = json.loads(resp_body)
except Exception:
data = {"_raw": resp_body.decode(errors="replace")}
return status, data
def _delete(url: str, headers: dict | None = None, timeout: int = 15) -> int:
status, _ = _http("DELETE", url, headers=headers, timeout=timeout)
return status
# ---------------------------------------------------------------------------
# HMAC helper for /webhook/plane
# ---------------------------------------------------------------------------
def _sign_payload(secret: str, body: bytes) -> str:
"""Compute HMAC-SHA256 signature — matches verify_plane_signature in plane.py."""
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# ---------------------------------------------------------------------------
# Result tracking
# ---------------------------------------------------------------------------
class Results:
def __init__(self):
self._items: list[tuple[str, bool, str]] = [] # (label, passed, detail)
def add(self, label: str, passed: bool, detail: str = ""):
self._items.append((label, passed, detail))
line = _ok(label) if passed else _fail(label)
if detail:
line += f" [{detail}]"
print(line)
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
print()
print(f"{_BOLD}{'='*60}{_RESET}")
print(f"{colour}{_BOLD} RESULT: {passed}/{total} checks PASS{_RESET}")
print(f"{_BOLD}{'='*60}{_RESET}")
return all_ok
# ---------------------------------------------------------------------------
# Block A — SMOKE
# ---------------------------------------------------------------------------
def block_a(base: str, results: Results):
print(f"\n{_BOLD}[Block A] SMOKE{_RESET}")
# A1 — /health
try:
status, data = _get(f"{base}/health")
ok = status == 200 and data.get("status") == "ok"
results.add("A1 GET /health → 200 status=ok", ok,
f"HTTP {status}, body={data}")
except Exception as e:
results.add("A1 GET /health → 200 status=ok", False, str(e))
# A2 — /queue
try:
status, data = _get(f"{base}/queue")
ok = (status == 200
and "counts" in data
and "max_concurrency" in data
and "resilience" in data)
results.add("A2 GET /queue → 200 with counts/max_concurrency/resilience", ok,
f"HTTP {status}, keys={list(data.keys())}")
except Exception as e:
results.add("A2 GET /queue → 200 with counts/max_concurrency/resilience", False, str(e))
# A3 — ORCH_STAGING=true in env (guard against hitting prod)
staging_flag = os.environ.get("ORCH_STAGING", "").lower()
ok = staging_flag == "true"
results.add("A3 ORCH_STAGING=true (not prod)", ok,
f"ORCH_STAGING={os.environ.get('ORCH_STAGING', '<unset>')}")
if not ok:
print(_fail(" ⛔ Safety abort: ORCH_STAGING is not 'true'. "
"This might be prod. Skipping destructive blocks B/C."))
sys.exit(2)
# ---------------------------------------------------------------------------
# Block B — ACCESS
# ---------------------------------------------------------------------------
SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"
PROD_ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
PROD_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
B6_LABEL = "B6 Registry: sandbox present, prod ET/ORCH absent"
def _evaluate_b6(known: set[str]) -> tuple[bool, str]:
"""Pure verdict logic for the B6 registry-isolation check (ORCH-048).
PASS ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known (TR-2).
``detail`` keeps the human-readable ``sandbox=…, prod-ET=…, prod-ORCH=…``
format (TR-3). Isolated from any I/O so both outcomes are unit-testable
without a live staging instance or docker (02-trz §9, ADR-001).
"""
sandbox_present = SANDBOX_PROJECT_ID in known
et_absent = PROD_ET_PROJECT_ID not in known
orch_absent = PROD_ORCH_PROJECT_ID not in known
passed = sandbox_present and et_absent and orch_absent
detail = (
f"sandbox={'YES' if sandbox_present else 'NO'}, "
f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
)
return passed, detail
def _known_project_ids_from_registry() -> set[str]:
"""Registry of the *running staging instance* — its own process-env (ORCH-048).
The suite is canonically run INSIDE ``orchestrator-staging`` via
``docker exec`` (ADR-001), so ``src.projects`` resolves through the
container's ``PYTHONPATH=/app`` to ``/app/src/projects.py`` and reads
``ORCH_PROJECTS_JSON`` from ``.env.staging``. This reflects exactly the
registry the live instance serves webhooks with — no host-path hack, no HTTP
bootstrap dependency.
"""
from src.projects import known_plane_project_ids
return known_plane_project_ids()
def _run_b6(results: Results) -> None:
"""Run the B6 registry-isolation check and record its verdict.
Builds the known-id set from the running instance's registry and applies
``_evaluate_b6``. Any failure to obtain the registry yields a deterministic
FAIL with a clear detail (TR-4) — never an unhandled exception and never a
false PASS.
"""
try:
known = _known_project_ids_from_registry()
except Exception as e:
results.add(B6_LABEL, False, f"registry source unavailable: {e}")
return
passed, detail = _evaluate_b6(known)
results.add(B6_LABEL, passed, detail)
def block_b(results: Results):
print(f"\n{_BOLD}[Block B] ACCESS{_RESET}")
plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
plane_base_env = os.environ.get("ORCH_PLANE_API_URL", "http://localhost:8091")
# env stores URL WITHOUT /api/v1 — add it ourselves
plane_base = plane_base_env.rstrip("/") + "/api/v1"
workspace = os.environ.get("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj")
gitea_token = os.environ.get("ORCH_GITEA_TOKEN", "")
gitea_base = os.environ.get("ORCH_GITEA_URL", "http://localhost:3000")
plane_headers = {"X-API-Key": plane_token}
gitea_headers = {"Authorization": f"token {gitea_token}"}
# B4 — Plane: list projects, sandbox id present
try:
url = f"{plane_base}/workspaces/{workspace}/projects/"
status, data = _get(url, headers=plane_headers)
if status == 200:
# API may return a list or {"results": [...]}
projects = data.get("results", data) if isinstance(data, dict) else data
if isinstance(projects, list):
ids = {p.get("id", "") for p in projects}
else:
ids = set()
ok = SANDBOX_PROJECT_ID in ids
results.add("B4 Plane: sandbox project accessible", ok,
f"HTTP {status}, found {len(ids)} project(s), sandbox={'YES' if ok else 'NO'}")
else:
results.add("B4 Plane: sandbox project accessible", False,
f"HTTP {status}")
except Exception as e:
results.add("B4 Plane: sandbox project accessible", False, str(e))
# B5 — Gitea: sandbox repo accessible, push=true
try:
url = f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox"
status, data = _get(url, headers=gitea_headers)
push_ok = data.get("permissions", {}).get("push", False) if status == 200 else False
ok = status == 200 and push_ok
results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", ok,
f"HTTP {status}, permissions={data.get('permissions')}")
except Exception as e:
results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", False, str(e))
# B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs (ORCH-048).
# Reads the registry of the running staging instance from its own process-env
# (canonical: docker exec inside orchestrator-staging — ADR-001). No host-path
# hack; deterministic FAIL if the registry source is unavailable (TR-4).
_run_b6(results)
# ---------------------------------------------------------------------------
# Block C — E2E
# ---------------------------------------------------------------------------
IN_PROGRESS_STATE_ID = "b873d9eb-993c-48cd-97ac-99a9b1623967"
# Path to staging SQLite DB inside the container
STAGING_DB_PATH = os.environ.get("ORCH_DB_PATH", "/app/data/orchestrator.db")
def _make_webhook_payload(issue_id: str, issue_name: str, issue_desc: str) -> dict:
"""Build the minimal webhook payload that triggers start_pipeline."""
return {
"event": "issue",
"action": "updated",
"data": {
"id": issue_id,
"name": issue_name,
"description_stripped": issue_desc,
"project": SANDBOX_PROJECT_ID,
"state": {
"id": IN_PROGRESS_STATE_ID,
"name": "In Progress",
"group": "started",
},
},
}
def _poll(fn, timeout: int = 60, interval: int = 3, label: str = ""):
"""Poll fn() until it returns truthy or timeout expires."""
deadline = time.time() + timeout
while time.time() < deadline:
result = fn()
if result:
return result
if label:
print(_info(f" waiting... ({label})"))
time.sleep(interval)
return None
def _cleanup_staging_db(plane_issue_id: str):
"""Delete the test task row from staging SQLite DB."""
if not plane_issue_id:
print(_info("CLEANUP DB: no issue_id to clean"))
return
try:
import sqlite3
conn = sqlite3.connect(STAGING_DB_PATH)
cur = conn.execute(
"DELETE FROM tasks WHERE plane_id = ?", (plane_issue_id,)
)
deleted = cur.rowcount
conn.commit()
conn.close()
if deleted:
print(_ok(f"CLEANUP DB: deleted {deleted} task row(s) for plane_id={plane_issue_id}"))
else:
print(_info(f"CLEANUP DB: no task row found for plane_id={plane_issue_id}"))
except Exception as e:
print(_fail(f"CLEANUP DB: error: {e}"))
def _cleanup_staging_jobs(plane_issue_id: str):
"""Delete job queue rows for the test task from staging SQLite DB."""
if not plane_issue_id:
return
try:
import sqlite3
conn = sqlite3.connect(STAGING_DB_PATH)
# Find task ids for this plane_id first
task_rows = conn.execute(
"SELECT id FROM tasks WHERE plane_id = ?", (plane_issue_id,)
).fetchall()
if task_rows:
task_ids = [r[0] for r in task_rows]
placeholders = ",".join("?" * len(task_ids))
cur = conn.execute(
f"DELETE FROM jobs WHERE task_id IN ({placeholders})", task_ids
)
deleted = cur.rowcount
conn.commit()
if deleted:
print(_ok(f"CLEANUP DB: deleted {deleted} job row(s) for task_ids={task_ids}"))
conn.close()
except Exception as e:
print(_fail(f"CLEANUP DB jobs: error: {e}"))
def _cleanup_dedup(plane_issue_id: str, wh_body_sha: str | None = None):
"""Remove dedup event entries for the test webhook delivery."""
if not wh_body_sha:
return
try:
import sqlite3
conn = sqlite3.connect(STAGING_DB_PATH)
cur = conn.execute(
"DELETE FROM events_dedup WHERE delivery_id = ?", (wh_body_sha,)
)
deleted = cur.rowcount
conn.commit()
conn.close()
if deleted:
print(_ok(f"CLEANUP DB: removed {deleted} dedup entry"))
except Exception as e:
# dedup table might not exist or different schema — not critical
print(_info(f"CLEANUP DB dedup: {e}"))
def block_c(base: str, results: Results, mode: str):
print(f"\n{_BOLD}[Block C] E2E (mode={mode}){_RESET}")
plane_token = os.environ.get("ORCH_PLANE_API_TOKEN", "")
plane_base_env = os.environ.get("ORCH_PLANE_API_URL", "http://localhost:8091")
plane_base = plane_base_env.rstrip("/") + "/api/v1"
workspace = os.environ.get("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj")
gitea_token = os.environ.get("ORCH_GITEA_TOKEN", "")
gitea_base = os.environ.get("ORCH_GITEA_URL", "http://localhost:3000")
webhook_secret = os.environ.get("ORCH_PLANE_WEBHOOK_SECRET", "")
plane_headers = {"X-API-Key": plane_token}
gitea_headers = {"Authorization": f"token {gitea_token}"}
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S")
issue_name = f"[staging-check] e2e {ts}"
issue_desc = (
"Automated e2e check created by staging_check.py. "
"This task tests the live staging pipeline end-to-end. "
"Safe to delete — cleanup runs in finally block."
)
issue_id = None
branch_name = None
wh_body_bytes = None
try:
# C7 — Create task in Plane SANDBOX
print(_info(f"C7: Creating issue in SANDBOX project..."))
url = f"{plane_base}/workspaces/{workspace}/projects/{SANDBOX_PROJECT_ID}/issues/"
status, data = _post(url, headers=plane_headers, payload={
"name": issue_name,
"description_html": f"<p>{issue_desc}</p>",
"description_stripped": issue_desc,
})
issue_id = data.get("id")
ok = status in (200, 201) and bool(issue_id)
results.add("C7 Create issue in Plane SANDBOX", ok,
f"HTTP {status}, issue_id={issue_id}")
if not ok:
print(_fail(f" Cannot continue C8-C9 without issue. body={data}"))
results.add("C8 Trigger pipeline via /webhook/plane", False, "skipped: C7 failed")
results.add("C9a Branch appears in orchestrator-sandbox", False, "skipped")
results.add("C9b Analyst job enqueued in staging queue", False, "skipped")
return
# Small delay to let Plane finish persisting the issue
time.sleep(2)
# C8 — Trigger pipeline via direct POST to /webhook/plane
print(_info(f"C8: Triggering pipeline via POST /webhook/plane ..."))
wh_payload = _make_webhook_payload(issue_id, issue_name, issue_desc)
wh_body_bytes = json.dumps(wh_payload).encode()
wh_headers = {"Content-Type": "application/json"}
if webhook_secret:
sig = _sign_payload(webhook_secret, wh_body_bytes)
wh_headers["X-Plane-Signature"] = sig
print(_info(f" Using HMAC signature (secret len={len(webhook_secret)})"))
else:
print(_info(" No webhook secret configured, sending without signature"))
status, resp = _post(f"{base}/webhook/plane",
headers=wh_headers,
raw_body=wh_body_bytes)
ok = status == 200 and resp.get("status") in ("accepted",)
results.add("C8 Trigger pipeline via /webhook/plane", ok,
f"HTTP {status}, resp={resp}")
if not ok:
print(_fail(f" Pipeline trigger failed. Cannot verify C9."))
results.add("C9a Branch appears in orchestrator-sandbox", False, "skipped: C8 failed")
results.add("C9b Analyst job enqueued in staging queue", False, "skipped: C8 failed")
return
# C9a — Poll for branch in Gitea orchestrator-sandbox
print(_info("C9a: Polling for branch in orchestrator-sandbox (up to 60s)..."))
def _check_branch():
try:
burl = f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox/branches"
s, bdata = _get(burl, headers=gitea_headers)
if s != 200:
return None
branches = bdata if isinstance(bdata, list) else bdata.get("results", [])
for b in branches:
bname = b.get("name", "")
# Branch name: feature/SANDBOX-NNN-staging-check-...
if "feature/" in bname and "staging-check" in bname:
return bname
return None
except Exception:
return None
branch_name = _poll(_check_branch, timeout=60, interval=3,
label="waiting for branch")
ok = bool(branch_name)
results.add("C9a Branch appears in orchestrator-sandbox", ok,
f"branch={branch_name or 'not found'}")
# C9b — Verify analyst job was enqueued via staging /queue
# NOTE: The orchestrator posts a "🔍 Analyst запущен" comment to Plane using
# per-agent bot tokens (ORCH_PLANE_BOT_ANALYST). In staging, the sandbox
# project was created after the bot accounts were provisioned, so the bots are
# not yet members of the SANDBOX project → add_comment returns 403 Forbidden.
# This is a known staging infrastructure limitation (not a pipeline bug).
# We therefore verify pipeline success via /queue (recent jobs): the analyst
# job is enqueued BEFORE the add_comment call, so its presence in the queue
# confirms the pipeline ran through to job dispatch.
print(_info("C9b: Checking staging job queue for analyst job (up to 30s)..."))
print(_info(" (Plane comment check skipped: bot-tokens not added to SANDBOX project)"))
def _check_queue():
try:
s, qdata = _get(f"{base}/queue")
if s != 200:
return None
recent = qdata.get("recent", [])
for job in recent:
if (job.get("agent") == "analyst"
and job.get("repo") == "orchestrator-sandbox"
and issue_name in (job.get("task_content") or "")):
return job
return None
except Exception:
return None
analyst_job = _poll(_check_queue, timeout=30, interval=2,
label="waiting for analyst job in queue")
ok = bool(analyst_job)
detail = ""
if analyst_job:
detail = (f"job_id={analyst_job.get('id')}, "
f"status={analyst_job.get('status')}, "
f"agent={analyst_job.get('agent')}")
results.add("C9b Analyst job enqueued in staging queue", ok, detail)
finally:
# C10 — CLEANUP (always runs)
print(f"\n{_BOLD}[CLEANUP]{_RESET}")
_cleanup(
plane_base=plane_base,
workspace=workspace,
gitea_base=gitea_base,
plane_headers=plane_headers,
gitea_headers=gitea_headers,
issue_id=issue_id,
branch_name=branch_name,
wh_body_bytes=wh_body_bytes,
)
def _cleanup(plane_base, workspace, gitea_base, plane_headers, gitea_headers,
issue_id, branch_name, wh_body_bytes=None):
"""Delete test branch in Gitea, test issue in Plane SANDBOX, and DB rows."""
# Delete branch in Gitea
if branch_name:
try:
burl = (f"{gitea_base}/api/v1/repos/admin/orchestrator-sandbox"
f"/branches/{urllib.parse.quote(branch_name, safe='')}")
s = _delete(burl, headers=gitea_headers)
if s in (200, 204, 404):
print(_ok(f"CLEANUP: deleted branch {branch_name!r} (HTTP {s})"))
else:
print(_fail(f"CLEANUP: delete branch returned HTTP {s}"))
except Exception as e:
print(_fail(f"CLEANUP: delete branch error: {e}"))
else:
print(_info("CLEANUP: no branch to delete"))
# Delete issue in Plane SANDBOX
if issue_id:
try:
iurl = (f"{plane_base}/workspaces/{workspace}/projects/"
f"{SANDBOX_PROJECT_ID}/issues/{issue_id}/")
s = _delete(iurl, headers=plane_headers)
if s in (200, 204, 404):
print(_ok(f"CLEANUP: deleted Plane issue {issue_id} (HTTP {s})"))
else:
print(_fail(f"CLEANUP: delete Plane issue returned HTTP {s}"))
except Exception as e:
print(_fail(f"CLEANUP: delete Plane issue error: {e}"))
else:
print(_info("CLEANUP: no issue to delete"))
# Delete task + jobs from staging DB
if issue_id:
_cleanup_staging_jobs(issue_id)
_cleanup_staging_db(issue_id)
# Remove dedup entry so future re-runs with same body don't get "duplicate"
if wh_body_bytes is not None:
import hashlib as _hl
dedup_id = "plane" + _hl.sha256(b"plane" + wh_body_bytes).hexdigest()
_cleanup_dedup(issue_id, dedup_id)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Live staging-stand check suite (ORCH-33)"
)
parser.add_argument(
"--base-url",
default="http://localhost:8501",
help="Base URL of the staging orchestrator (default: http://localhost:8501)",
)
parser.add_argument(
"--mode",
choices=["stub", "full-real"],
default="stub",
help=(
"stub (default): check early pipeline artifacts only (branch+job), "
"no LLM spend. "
"full-real: also wait for the analyst agent (slow, costs credits)."
),
)
args = parser.parse_args()
base = args.base_url.rstrip("/")
print(f"{_BOLD}{'='*60}{_RESET}")
print(f"{_BOLD} ORCH-33 Staging Check Suite{_RESET}")
print(f" base_url : {base}")
print(f" mode : {args.mode}")
print(f" utc_time : {datetime.datetime.now(datetime.timezone.utc).isoformat()}")
print(f"{_BOLD}{'='*60}{_RESET}")
results = Results()
block_a(base, results)
block_b(results)
block_c(base, results, args.mode)
all_ok = results.summary()
sys.exit(0 if all_ok else 1)
if __name__ == "__main__":
main()

View File

@@ -15,6 +15,82 @@ from ..plane_sync import notify_stage_change as plane_notify_stage, add_comment
logger = logging.getLogger("orchestrator.launcher")
# ORCH-41: valid --effort values accepted by the Claude CLI. An effort that is
# not in this set is treated as misconfiguration: logged and dropped (no flag),
# never passed through to the CLI.
VALID_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
def _resolve_agent_attr(agent, project_id, project_map_attr, env_attr_prefix,
default_attr):
"""ORCH-41 shared resolver with priority:
1. ProjectConfig.<project_map_attr>[agent] (per-project override)
2. settings.<env_attr_prefix><agent> (per-agent env, if non-empty)
3. settings.<default_attr> (global default)
4. "" (no flag -> CLI default)
project_id is the Plane project uuid. It is resolved to a ProjectConfig via
the registry; an unknown / empty id simply skips level 1. A missing per-agent
settings attribute (e.g. unknown agent name) skips level 2.
"""
# Level 1: per-project override.
if project_id:
from ..projects import get_project_by_plane_id
proj = get_project_by_plane_id(project_id)
if proj is not None:
override = getattr(proj, project_map_attr, {}).get(agent)
if override:
return override
# Level 2: per-agent env (settings.<prefix><agent>), if defined & non-empty.
per_agent = getattr(settings, f"{env_attr_prefix}{agent}", "")
if per_agent:
return per_agent
# Level 3: global default.
default = getattr(settings, default_attr, "")
if default:
return default
# Level 4: nothing -> CLI default.
return ""
def resolve_agent_model(agent: str, project_id: str = None) -> str:
"""ORCH-41: resolve the LLM model for an agent (optionally per-project).
Returns "" when no model is configured at any level -> caller omits --model
and the CLI default applies. See _resolve_agent_attr for the priority order.
"""
return _resolve_agent_attr(
agent, project_id,
project_map_attr="agent_models",
env_attr_prefix="agent_model_",
default_attr="agent_model_default",
)
def resolve_agent_effort(agent: str, project_id: str = None) -> str:
"""ORCH-41: resolve the --effort level for an agent (optionally per-project).
Same priority as resolve_agent_model. The resolved value is validated against
VALID_EFFORTS; an invalid value is logged and dropped (returns "") so a typo
in env/projects_json can never pass a bad flag to the CLI.
"""
value = _resolve_agent_attr(
agent, project_id,
project_map_attr="agent_efforts",
env_attr_prefix="agent_effort_",
default_attr="agent_effort_default",
)
if value and value not in VALID_EFFORTS:
logger.warning(
f"Invalid effort '{value}' for agent '{agent}' "
f"(allowed: {sorted(VALID_EFFORTS)}); omitting --effort"
)
return ""
return value
def prune_run_logs(runs_dir, keep_days=30, keep_max=500, active_paths=None):
"""L-2: best-effort rotation of per-run logs (<runs_dir>/*.log).
@@ -85,7 +161,6 @@ class AgentLauncher:
"system_prompt": ".openclaw/agents/architect.md",
"task_file": ".task-arch.md",
"allowed_tools": "Read,Write,Edit,Bash",
"model": "opus",
},
"developer": {
"system_prompt": ".openclaw/agents/developer.md",
@@ -96,7 +171,6 @@ class AgentLauncher:
"system_prompt": ".openclaw/agents/reviewer.md",
"task_file": ".task-review.md",
"allowed_tools": "Read,Write,Edit,Bash",
"model": "opus",
},
"tester": {
"system_prompt": ".openclaw/agents/tester.md",
@@ -171,6 +245,12 @@ class AgentLauncher:
_br_row = get_db().execute("SELECT branch FROM tasks WHERE id=?", (task_id,)).fetchone() if task_id else None
agent_branch = _br_row[0] if _br_row else "main"
# ORCH-41: resolve the Plane project uuid for this repo so per-project
# model/effort overrides apply. Unknown repo -> None (env/default only).
from ..projects import get_project_by_repo
_proj = get_project_by_repo(repo)
project_id = _proj.plane_project_id if _proj else None
# Ensure the per-branch worktree exists and is on the right branch.
work_path = ensure_worktree(repo, agent_branch)
@@ -204,8 +284,14 @@ class AgentLauncher:
system_prompt = config["system_prompt"]
allowed_tools = config["allowed_tools"]
model = config.get("model", "")
# ORCH-41: model + effort + optional fallback are resolved from config
# (project-override > per-agent env > default), not hardcoded in AGENT_CONFIGS.
model = resolve_agent_model(agent, project_id)
effort = resolve_agent_effort(agent, project_id)
model_flag = f"--model {model} " if model else ""
effort_flag = f"--effort {effort} " if effort else ""
fb = settings.agent_fallback_model
fb_flag = f"--fallback-model {fb} " if fb else ""
# No git fetch/checkout here: ensure_worktree() already put the worktree on
# the right branch. The agent simply runs inside its isolated work_path.
@@ -218,7 +304,7 @@ class AgentLauncher:
f'cd {work_path} && '
f'{self.CLAUDE_BIN} --print '
f'--output-format json '
f'{model_flag}'
f'{model_flag}{effort_flag}{fb_flag}'
f'"$(cat {task_file})" '
f'--system-prompt "$(cat {system_prompt})" '
f'--allowedTools {allowed_tools}'
@@ -507,11 +593,15 @@ class AgentLauncher:
from ..notifications import send_telegram
send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
# Feature 4: post the per-agent usage comment under that agent's bot, and
# — for the deployer finishing the task — the per-task usage summary.
# Feature 4 + ORCH-016: post the unified per-agent status comment under
# that agent's bot, threading the wall-clock duration we just measured
# straight through (ADR-001 §6: explicit param wins over DB fallback).
# The deployer finishing the task also posts the per-task usage summary.
if exit_code == 0:
try:
self._post_usage_comments(run_id, agent, repo, branch, _usage)
self._post_usage_comments(
run_id, agent, repo, branch, _usage, duration_s=_duration_s
)
except Exception as e:
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
@@ -679,42 +769,67 @@ class AgentLauncher:
logger.error(f"Auto-advance failed for run_id={run_id}: {e}")
def _post_usage_comments(self, run_id, agent, repo, branch, usage):
"""Feature 4: post the per-agent usage comment (and Deployer summary).
def _post_usage_comments(self, run_id, agent, repo, branch, usage, duration_s=None):
"""Feature 4 + ORCH-016: post the unified per-agent status comment.
- Always (on success, with a work_item_id): a per-agent finish comment
with token/cost, authored by the finishing agent's Plane bot.
via ``usage.build_status_comment(...)``, authored by the finishing
agent's Plane bot. The comment carries:
* single-line header (icon + role + per-stage description),
* machine verdict line for reviewer / tester / deployer (when the
relevant frontmatter is present in the worktree),
* the agent's wall-clock duration (``duration_s`` is the measured
value in _monitor_agent; DB fallback is unused on this path),
* an HTML <ul> of artifact links scoped per agent,
* a ``<sub>`` token/cost tail.
- When the deployer finishes: also a per-task summary (SUM over
agent_runs GROUP BY agent), authored by the deployer.
The deployer's `stage=` is resolved from the task row so the helper can
pick between 14-deploy-log.md (prod) and 15-staging-log.md (staging).
"""
from ..usage import usage_comment, task_summary_comment
from ..usage import build_status_comment, task_summary_comment
from ..git_worktree import get_worktree_path
conn = get_db()
row = conn.execute(
"SELECT id, work_item_id FROM tasks WHERE repo=? AND branch=?",
"SELECT id, work_item_id, stage FROM tasks WHERE repo=? AND branch=?",
(repo, branch),
).fetchone()
conn.close()
if not row:
return
task_id, work_item_id = row[0], row[1]
task_id, work_item_id, stage = row[0], row[1], row[2]
if not work_item_id:
return
# Observability: every agent's finish comment links its artifact(s)
# (reviewer->12-review, tester->13-test-report, deployer->14-deploy-log,
# (reviewer->12-review, tester->13-test-report, deployer->14- or 15-,
# architect->ADR, developer->PR/branch). For the developer we resolve the
# open PR number so the link points straight at it.
pr_number = None
if agent == "developer":
pr_number = self._open_pr_number(repo, branch)
# Best-effort worktree path — drives AC-8 (skip missing artifacts) and
# the verdict frontmatter read. Falls back to None on lookup error so
# the comment still goes out without the verdict line / file probe.
try:
worktree_root = get_worktree_path(repo, branch)
except Exception:
worktree_root = None
plane_add_comment(
work_item_id,
usage_comment(
build_status_comment(
agent,
usage,
repo=repo,
branch=branch,
work_item_id=work_item_id,
pr_number=pr_number,
stage=stage,
usage=usage,
duration_s=duration_s,
task_id=task_id,
worktree_root=worktree_root,
),
author=agent,
)

View File

@@ -4,6 +4,11 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Plane
plane_api_url: str = "http://localhost:8091"
# ORCH-017: external (browser) web URL of Plane for clickable issue links in
# notifications, e.g. https://plane.example.org. Falls back to plane_api_url,
# but a loopback fallback (localhost/127.0.0.1) is treated as "no web URL" and
# the Plane link is omitted (see notifications._build_plane_issue_link).
plane_web_url: str = ""
plane_api_token: str = ""
plane_workspace_slug: str = ""
plane_webhook_secret: str = ""
@@ -78,6 +83,34 @@ class Settings(BaseSettings):
agent_kill_grace_seconds: int = 20
agent_timeout_overrides_json: str = ""
# ORCH-41: per-agent LLM model. Empty -> agent_model_default. Resolution order:
# project-override (projects_json agent_models) > ORCH_AGENT_MODEL_<AGENT> >
# agent_model_default > CLI default (no --model flag). Default is 4-8 because
# 4-7 == 4-8 in price (Slava 05.06); do NOT hardcode the version anywhere else.
agent_model_default: str = "claude-opus-4-8"
agent_model_analyst: str = ""
agent_model_architect: str = ""
agent_model_developer: str = ""
agent_model_reviewer: str = ""
agent_model_tester: str = ""
agent_model_deployer: str = ""
# ORCH-41: per-agent effort / reasoning level: low|medium|high|xhigh|max.
# Empty -> agent_effort_default. Same resolution order as model. Default split:
# thinking agents (analyst/architect/developer/reviewer) -> high; mechanical
# agents (tester/deployer) -> medium.
agent_effort_default: str = "high"
agent_effort_analyst: str = "high"
agent_effort_architect: str = "high"
agent_effort_developer: str = "high"
agent_effort_reviewer: str = "high"
agent_effort_tester: str = "medium"
agent_effort_deployer: str = "medium"
# ORCH-41: optional per-agent fallback model used when the primary is
# overloaded (--fallback-model, works with --print). Empty -> no flag.
agent_fallback_model: str = ""
# L-2: run-log rotation. Old per-run logs in <data>/runs/*.log are pruned at
# app startup (best-effort). A *.log is removed if it is older than
# log_keep_days OR not within the log_keep_max most-recent logs (whichever
@@ -88,6 +121,15 @@ class Settings(BaseSettings):
log_keep_max: int = 500
# ORCH-045: quality-gate CI poll/retry. check_ci_green polls the Gitea
# combined commit status up to ci_poll_max_attempts times, sleeping
# ci_poll_interval_s between attempts, to ride out a transient pending
# state right after the developer push (race fix, see ORCH-017).
# ci_poll_max_attempts -> max status polls (env ORCH_CI_POLL_MAX_ATTEMPTS)
# ci_poll_interval_s -> seconds between polls (env ORCH_CI_POLL_INTERVAL_S)
ci_poll_max_attempts: int = 12
ci_poll_interval_s: int = 10
# Telegram notifications
telegram_bot_token: str = ""
telegram_chat_id: str = ""

75
src/frontmatter.py Normal file
View File

@@ -0,0 +1,75 @@
"""Safe single-key YAML frontmatter reader (ORCH-016 / ADR-001 §5).
The status-comment builder (build_status_comment) needs to surface verdict /
deploy_status / staging_status from the per-stage artifact files (12-review.md,
13-test-report.md, 14-deploy-log.md, 15-staging-log.md). Those files share the
same leading-YAML-frontmatter convention used by the quality gates — but the
comment hot-path must NEVER raise: a missing file, malformed YAML, or absent
key should simply suppress the verdict line, not break the run.
This module is a tiny defensive helper:
- `read_frontmatter_value(path, key)` -> str | None
- swallows every exception, logs to logger.debug, returns None.
It intentionally duplicates ~10 lines of YAML-frontmatter logic that already
exist in `src/qg/checks.py` (S-5 / БАГ 8 / ET-013 fixes). ADR-001 §5 accepts
this duplication to keep the blast radius of ORCH-016 small (no QG refactor in
this PR); merging into a single parser is a follow-up task.
"""
import logging
logger = logging.getLogger("orchestrator.frontmatter")
def read_frontmatter_value(path: str, key: str) -> str | None:
"""Return the value of `key` from the leading YAML frontmatter of `path`.
Format expected (canonical, matching qg/checks.py):
---
key: value
other: ...
---
<body>
Never raises. Returns None for any of:
- missing/unreadable file,
- no leading `---` frontmatter,
- malformed/unterminated frontmatter,
- YAML parse error,
- frontmatter is not a mapping,
- key absent (or its value is None/empty).
The returned value is stringified and stripped (whitespace removed); casing
is preserved so the caller decides whether to upper/lower for matching.
"""
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
logger.debug(f"read_frontmatter_value: cannot open {path}: {e}")
return None
if not content.startswith("---"):
return None
parts = content.split("---", 2)
if len(parts) < 3:
# Unterminated frontmatter.
return None
try:
import yaml
fm = yaml.safe_load(parts[1]) or {}
except Exception as e: # yaml.YAMLError + anything pyyaml may surface
logger.debug(f"read_frontmatter_value: yaml parse failed for {path}: {e}")
return None
if not isinstance(fm, dict):
return None
raw = fm.get(key)
if raw is None:
return None
value = str(raw).strip()
return value or None

View File

@@ -68,16 +68,43 @@ def send_telegram(text: str, disable_notification: bool = False):
return None
def edit_telegram(message_id: int, text: str) -> bool:
"""Edit an existing Telegram message. Returns True on success, else False.
# edit_telegram outcome codes -> let update_task_tracker decide what to do:
# "ok" edit applied -> nothing else to do
# "not_modified" Telegram says text is identical (400 "message is not
# modified" / "exactly the same") -> success, NO new message
# "gone" original message can't be edited (deleted / too old /
# invalid id) -> caller must fall back to a NEW message
# "failed" transient failure (network / timeout / 5xx / unknown 400)
# -> caller must NOT send a new message (avoid duplicates)
EDIT_OK = "ok"
EDIT_NOT_MODIFIED = "not_modified"
EDIT_GONE = "gone"
EDIT_FAILED = "failed"
Used by the live tracker to refresh the single per-task message in place.
Never raises. A False return tells the caller to fall back to a new message
(e.g. the message is too old to edit / was deleted / 400).
# Telegram error descriptions that mean the message is permanently un-editable
# (it is gone / orphaned) -> fall back to a fresh message.
_GONE_MARKERS = (
"message to edit not found",
"message can't be edited",
"message_id_invalid",
)
# Telegram "nothing changed" -> treat as success, never a duplicate.
_NOT_MODIFIED_MARKERS = (
"message is not modified",
"exactly the same",
)
def edit_telegram(message_id: int, text: str) -> str:
"""Edit an existing Telegram message. Never raises.
Returns a distinguishable outcome (see EDIT_* constants) so the caller can
tell apart "all good" / "nothing changed" / "message gone" / "transient
failure" and only fall back to a NEW message when the original is truly gone.
"""
s = _get_settings()
if not s.telegram_bot_token or not s.telegram_chat_id:
return False
return EDIT_FAILED
try:
url = f"https://api.telegram.org/bot{s.telegram_bot_token}/editMessageText"
resp = httpx.post(
@@ -91,9 +118,32 @@ def edit_telegram(message_id: int, text: str) -> bool:
timeout=5,
)
data = resp.json()
return bool(data.get("ok"))
except Exception:
return False
if data.get("ok"):
return EDIT_OK
# ok:false -> inspect the description to classify the 400.
desc = str(data.get("description") or "").lower()
if any(m in desc for m in _NOT_MODIFIED_MARKERS):
# Text is identical between transitions (e.g. repeat review cycle
# renders the same line). Nothing to do, NOT a duplicate.
logger.debug(
f"edit_telegram(mid={message_id}): not modified, skipping"
)
return EDIT_NOT_MODIFIED
if any(m in desc for m in _GONE_MARKERS):
logger.warning(
f"edit_telegram(mid={message_id}): message gone ({desc!r}), "
f"will fall back to a new message"
)
return EDIT_GONE
# Unknown 400 / other non-ok -> transient/unknown, do NOT duplicate.
logger.warning(
f"edit_telegram(mid={message_id}): edit failed ({desc!r})"
)
return EDIT_FAILED
except Exception as e:
# Network / timeout / 5xx -> transient, do NOT duplicate.
logger.warning(f"edit_telegram(mid={message_id}): transient error: {e}")
return EDIT_FAILED
def _get_work_item_id(task_id: int) -> str:
@@ -280,11 +330,35 @@ def render_task_tracker(task_id: int) -> str:
for stage_key, label, agent in _TRACKER_STAGES:
run = last_done.get(agent)
if run is not None:
# The stage is "in progress" only when it is the task's current stage AND
# there is an unfinished run for its agent (the agent is actually still
# working). A finished run with no in-flight run -> show the \u2705 result,
# even if the task still sits in that stage (just-finished snapshot).
agent_runs = agent_runs_by_agent.get(agent, [])
has_inflight = any(ar["finished_at"] is None for ar in agent_runs)
is_active_stage = (
_STAGE_ACTIVE_AGENT.get(stage) == agent
and stage == stage_key
and (has_inflight or run is None)
)
if is_active_stage:
# Live "\U0001f504 ... \u0438\u0434\u0451\u0442" line. Count how many times THIS stage's
# agent has run for this task; a 2nd+ run means we're re-doing the
# stage (e.g. review->development->review), so show "\u043f\u043e\u043f\u044b\u0442\u043a\u0430 N"
# to make the text change between cycles and to honestly show Slava
# the stage is being re-worked.
attempt = len(agent_runs)
if attempt >= 2:
lines.append(
f"\U0001f504 {label} \u00b7 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 {attempt} "
f"\u2026 \u0438\u0434\u0451\u0442"
)
else:
lines.append(
f"\U0001f504 {label:<13} \u2026 \u00b7 \u0438\u0434\u0451\u0442"
)
elif run is not None:
lines.append(_stage_line(label, run))
elif _STAGE_ACTIVE_AGENT.get(stage) == agent and stage == stage_key:
# This stage is the active one and has no finished run yet.
lines.append(f"\U0001f504 {label:<13} \u2026 \u00b7 \u0438\u0434\u0451\u0442")
# else: not started yet -> not shown.
# Insert the BRD review line right after Analysis.
@@ -372,19 +446,32 @@ def update_task_tracker(task_id: int):
"""Render + push the live tracker for a task. Never raises.
First call (no stored tracker_message_id): sendMessage (silent) and store the
returned message_id. Subsequent calls: editMessageText the stored message; if
the edit fails (too old / deleted / 400), fall back to a NEW message and
update the stored id. The tracker is always sent with disable_notification so
it never pings — only the dedicated alert helpers ping.
returned message_id. Subsequent calls: editMessageText the stored message.
A NEW message is sent ONLY when the original is truly gone (deleted / too old
/ invalid id). On "not modified" (text unchanged) or transient failures
(network / timeout / 5xx / unknown 400) we do NOT send a new message — that
is exactly what produced duplicate trackers and orphaned (lagging) messages.
The tracker is always sent with disable_notification so it never pings —
only the dedicated alert helpers ping.
"""
try:
from .db import get_tracker_message_id, set_tracker_message_id
text = render_task_tracker(task_id)
mid = get_tracker_message_id(task_id)
if mid is not None:
if edit_telegram(mid, text):
result = edit_telegram(mid, text)
if result in (EDIT_OK, EDIT_NOT_MODIFIED):
# Edited in place (or nothing to change) -> done, no duplicate.
return
# Edit failed -> fall back to a fresh message.
if result == EDIT_FAILED:
# Transient -> don't duplicate; tracker redraws next transition.
logger.debug(
f"update_task_tracker({task_id}): edit failed transiently, "
f"keeping message {mid}"
)
return
# result == EDIT_GONE -> the stored message is gone; fall through
# to send a fresh one and re-point tracker_message_id at it.
new_mid = send_telegram(text, disable_notification=True)
if new_mid is not None:
set_tracker_message_id(task_id, new_mid)
@@ -457,6 +544,105 @@ def notify_qg_failure(task_id: int, stage: str, check: str, reason: str):
logger.warning(f"\u26a0\ufe0f {work_item_id}: QG {check} \u2014 failed: {reason}")
# ORCH-017: hosts that are not clickable off the deploy box. A Plane web-base
# resolving to one of these (the plane_api_url loopback default) means "no usable
# browser URL" -> the Plane link is omitted rather than emitted broken (ADR-001 Р-3).
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"})
def _is_loopback_base(url: str) -> bool:
"""True if the URL's host is a loopback/local address (not clickable off-host).
Empty/garbage URLs count as loopback (i.e. unusable) so callers omit the link.
"""
if not url:
return True
try:
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").lower()
return (not host) or host in _LOOPBACK_HOSTS
except Exception:
return True
def _get_task_link_fields(task_id: int):
"""ORCH-017: read (repo, branch, plane_issue_id) for a task. Never raises.
Returns (None, None, None) on any error / missing row so link building can
degrade gracefully (AC-6).
"""
try:
from .db import get_db
conn = get_db()
row = conn.execute(
"SELECT repo, branch, plane_issue_id FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
if not row:
return None, None, None
return row["repo"], row["branch"], row["plane_issue_id"]
except Exception as e:
logger.warning(f"_get_task_link_fields({task_id}) failed: {e}")
return None, None, None
def _build_brd_link(repo, branch, work_item_id) -> str | None:
"""ORCH-017: '<a>' to 01-brd.md in Gitea branch-view, or None if data missing.
Mirrors the canonical branch-view pattern in src/usage.py: base =
gitea_public_url or gitea_url, owner = gitea_owner (AC-1/AC-3). The href is
html.escaped as defence-in-depth even though parts come from trusted
config/DB (AC-7).
"""
s = _get_settings()
base = (
getattr(s, "gitea_public_url", "") or getattr(s, "gitea_url", "")
).rstrip("/")
owner = getattr(s, "gitea_owner", "")
if not (base and owner and repo and branch and work_item_id):
return None
url = (
f"{base}/{owner}/{repo}/src/branch/{branch}"
f"/docs/work-items/{work_item_id}/01-brd.md"
)
return (
f'<a href="{html.escape(url, quote=True)}">'
f"\U0001f4c4 Открыть BRD</a>"
)
def _build_plane_issue_link(repo, plane_issue_id) -> str | None:
"""ORCH-017: '<a>' to the Plane issue browser page, or None if unusable.
Full path per ADR-001 Р-2:
``{web_base}/{workspace_slug}/projects/{project_id}/issues/{issue_id}/``.
web_base = plane_web_url or plane_api_url (AC-3); a loopback base is treated
as "no web URL" and the link is omitted (loopback-guard, AC-2/AC-6).
"""
s = _get_settings()
web_base = (
getattr(s, "plane_web_url", "") or getattr(s, "plane_api_url", "")
).rstrip("/")
workspace = getattr(s, "plane_workspace_slug", "")
if not (web_base and workspace and plane_issue_id) or _is_loopback_base(web_base):
return None
try:
from .projects import get_project_by_repo
project = get_project_by_repo(repo) if repo else None
except Exception:
project = None
if not project or not getattr(project, "plane_project_id", ""):
return None
url = (
f"{web_base}/{workspace}/projects/{project.plane_project_id}"
f"/issues/{plane_issue_id}/"
)
return (
f'<a href="{html.escape(url, quote=True)}">'
f"✅ Задача в Plane</a>"
)
def notify_approve_requested(task_id: int):
"""ALERT (separate, notifying): BRD/TZ/AC ready -> flip Plane to Approved.
@@ -470,10 +656,27 @@ def notify_approve_requested(task_id: int):
except Exception as e:
logger.warning(f"notify_approve_requested: brd clock start failed: {e}")
msg = (
f"\U0001f4cb {work_item_id}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\U0001f4cb {html.escape(work_item_id)}: BRD/\u0422\u0417/AC \u0433\u043e\u0442\u043e\u0432\u044b. "
f"\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u0434\u0430\u0447\u0443 \u0432 \u0441\u0442\u0430\u0442\u0443\u0441 Approved "
f"\u0432 Plane \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f."
)
# ORCH-017: embed direct links to the BRD doc (Gitea) and the Plane issue so
# the reviewer can open both straight from the ping. Each link is built
# independently and omitted if its data is missing; building is defensive so
# it can NEVER break the alert (AC-1/AC-2/AC-6). Still exactly one notifying
# message (AC-5); the call to action above is always preserved (AC-4).
try:
repo, branch, plane_issue_id = _get_task_link_fields(task_id)
links = [
link for link in (
_build_brd_link(repo, branch, work_item_id),
_build_plane_issue_link(repo, plane_issue_id),
) if link
]
if links:
msg = msg + "\n\n" + "\n".join(links)
except Exception as e:
logger.warning(f"notify_approve_requested({task_id}): link build failed: {e}")
logger.info(msg)
update_task_tracker(task_id)
send_telegram(msg) # separate, notifying

View File

@@ -84,31 +84,131 @@ def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str
logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}")
return PROJECT_ID
# Plane state IDs.
# TODO(ORCH-10): these UUIDs are PER-PROJECT. The 6 stage-visibility / verdict
# statuses below were created only in the enduro project (7a79f0a9-...). One
# project is in prod today, so a single global dict is acceptable. When more
# projects are onboarded these must be resolved per project (see ORCH-10 in
# BACKLOG.md / the ORCH-6 project registry) — do NOT hardcode globally then.
PLANE_STATES = {
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
# ORCH-10: per-project state resolution.
#
# _DEFAULT_STATES keeps the original enduro-trails UUIDs as a safe fallback
# (used when the Plane API is unreachable and for backward compat).
# PLANE_STATES is preserved as an alias so existing call sites that reference
# it directly (QG-0 fast-path in webhooks/plane.py, tests) continue to work.
_DEFAULT_STATES = {
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
# Feature 3 (stage visibility) — per-stage statuses on the board.
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
# Feature 2 (verdict statuses) — Approved / Rejected.
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
}
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
PLANE_STATES = _DEFAULT_STATES
# Mapping: Plane state *name* (as returned by the API) -> logical key.
_PLANE_NAME_TO_KEY: dict[str, str] = {
"Backlog": "backlog",
"Todo": "todo",
"In Progress": "in_progress",
"Architecture": "architecture",
"Development": "development",
"Review": "review",
"Testing": "testing",
"Approved": "approved",
"Rejected": "rejected",
"Done": "done",
"Cancelled": "cancelled",
"Needs Input": "needs_input",
"In Review": "in_review",
"Blocked": "blocked",
}
# Per-project state cache: {project_id: {logical_key: state_uuid}}
_STATES_CACHE: dict[str, dict[str, str]] = {}
def get_project_states(project_id: str) -> dict[str, str]:
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
Source of truth: Plane API GET /projects/<project_id>/states/.
Results are cached per project_id for the lifetime of the process.
Falls back to _DEFAULT_STATES (enduro-trails values) if:
* project_id is empty/None,
* the API call fails (network error, non-2xx),
* the response contains no recognisable states.
The enduro-trails project therefore returns the same UUIDs as before
(backward compatible). The orchestrator project returns its own UUIDs,
fixing the ORCH-10 blocker.
"""
if not project_id:
return _DEFAULT_STATES
if project_id in _STATES_CACHE:
return _STATES_CACHE[project_id]
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
resp.raise_for_status()
body = resp.json()
# Plane returns {"results": [...]} or a bare list.
items = body.get("results", body) if isinstance(body, dict) else body
if not isinstance(items, list):
raise ValueError(f"unexpected states response shape: {type(items)}")
resolved: dict[str, str] = {}
for item in items:
name = item.get("name", "")
uid = item.get("id", "")
key = _PLANE_NAME_TO_KEY.get(name)
if key and uid:
resolved[key] = uid
if not resolved:
raise ValueError("no recognisable states in API response")
# Fill any missing keys from _DEFAULT_STATES so callers always get a
# complete mapping (defensive against partial Plane configs).
for k, v in _DEFAULT_STATES.items():
resolved.setdefault(k, v)
_STATES_CACHE[project_id] = resolved
logger.debug(
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
)
return resolved
except Exception as e:
logger.warning(
f"get_project_states: API failed for project {project_id[:8]}..., "
f"falling back to _DEFAULT_STATES. Error: {e}"
)
return _DEFAULT_STATES
def reload_project_states(project_id: str = None) -> None:
"""ORCH-10: clear the per-project states cache.
If project_id is given, evict only that project.
If None, flush the entire cache (useful in tests and after config reload).
"""
global _STATES_CACHE
if project_id is None:
_STATES_CACHE = {}
logger.debug("reload_project_states: full cache cleared")
else:
_STATES_CACHE.pop(project_id, None)
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
# when the pipeline ENTERS that stage. analysis stays driven by the existing
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
@@ -121,21 +221,44 @@ STAGE_VISIBILITY_STATE = {
"testing": "testing",
}
# Map orchestrator stages to Plane states (used by update_issue_state /
# notify_stage_change). Feature 3: architecture/development/review/testing now
# point at their dedicated board statuses so the task physically moves across
# columns. analysis -> in_progress, deploy -> in_progress, done -> done.
# STAGE_TO_STATE kept for backward compat (used by tests that patch it).
# update_issue_state now calls stage_to_state() instead of looking up here.
STAGE_TO_STATE = {
"created": PLANE_STATES["todo"],
"analysis": PLANE_STATES["in_progress"],
"architecture": PLANE_STATES["architecture"],
"development": PLANE_STATES["development"],
"review": PLANE_STATES["review"],
"testing": PLANE_STATES["testing"],
"deploy": PLANE_STATES["in_progress"],
"done": PLANE_STATES["done"],
"created": _DEFAULT_STATES["todo"],
"analysis": _DEFAULT_STATES["in_progress"],
"architecture": _DEFAULT_STATES["architecture"],
"development": _DEFAULT_STATES["development"],
"review": _DEFAULT_STATES["review"],
"testing": _DEFAULT_STATES["testing"],
"deploy": _DEFAULT_STATES["in_progress"],
"done": _DEFAULT_STATES["done"],
}
# Map orchestrator stage -> logical state key (project-independent).
_STAGE_TO_STATE_KEY = {
"created": "todo",
"analysis": "in_progress",
"architecture": "architecture",
"development": "development",
"review": "review",
"testing": "testing",
"deploy": "in_progress",
"done": "done",
}
def stage_to_state(stage: str, project_id: str) -> str | None:
"""ORCH-10: return the Plane state UUID for a pipeline stage in a project.
Resolves via get_project_states so the correct per-project UUID is used.
Returns None for unknown stages (same behaviour as the old STAGE_TO_STATE
dict lookup returning None).
"""
key = _STAGE_TO_STATE_KEY.get(stage)
if not key:
return None
return get_project_states(project_id).get(key)
def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
"""M-6: GET the Plane issue by UUID and return its sequence_id (the
@@ -284,11 +407,12 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
"""Update Plane issue state based on orchestrator stage."""
state_id = STAGE_TO_STATE.get(stage)
project_id = _resolve_project_id(work_item_id, project_id)
# ORCH-10: resolve state UUID for this specific project (not global dict).
state_id = stage_to_state(stage, project_id)
if not state_id:
return
project_id = _resolve_project_id(work_item_id, project_id)
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.warning(f"Issue not found in Plane for {work_item_id}")
@@ -327,20 +451,25 @@ def add_comment(work_item_id: str, text: str, project_id: str = None, author: st
logger.error(f"Failed to add comment to {work_item_id}: {e}")
def set_issue_needs_input(work_item_id: str, project_id: str = None):
"""Set issue to 'Needs Input' state — waiting for stakeholder response."""
_set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id)
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["needs_input"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_in_review(work_item_id: str, project_id: str = None):
"""Set issue to 'In Review' state — waiting for :approved: or :rejected:."""
_set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id)
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["in_review"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_blocked(work_item_id: str, project_id: str = None):
"""Set issue to 'Blocked' state — manual intervention needed."""
_set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id)
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["blocked"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_done(work_item_id: str, project_id: str = None):
@@ -348,15 +477,19 @@ def set_issue_done(work_item_id: str, project_id: str = None):
Used by the deploy->done success path so a completed task always reaches the
terminal Plane state (it used to stick on In Progress because the merge
webhook bypassed the stage engine). Uses the existing PLANE_STATES['done']
UUID — the mapping itself is NOT changed.
webhook bypassed the stage engine). Resolves per-project UUID via
get_project_states (ORCH-10).
"""
_set_issue_state_direct(work_item_id, PLANE_STATES["done"], project_id)
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["done"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_in_progress(work_item_id: str, project_id: str = None):
"""Set issue to 'In Progress' state — agent working."""
_set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id)
project_id = _resolve_project_id(work_item_id, project_id)
state_id = get_project_states(project_id)["in_progress"]
_set_issue_state_direct(work_item_id, state_id, project_id)
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
@@ -371,7 +504,10 @@ def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None)
state_key = STAGE_VISIBILITY_STATE.get(stage)
if not state_key:
return
_set_issue_state_direct(work_item_id, PLANE_STATES[state_key], project_id)
project_id = _resolve_project_id(work_item_id, project_id)
# ORCH-10: resolve per-project UUID.
state_id = get_project_states(project_id)[state_key]
_set_issue_state_direct(work_item_id, state_id, project_id)
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):

View File

@@ -17,7 +17,7 @@ registry is used so the system works out of the box.
import json
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from .config import settings
@@ -30,6 +30,11 @@ class ProjectConfig:
repo: str # gitea repo name (== folder under /repos)
work_item_prefix: str # ET / ORCH
name: str # human-readable label
# ORCH-41: optional per-project agent->model / agent->effort overrides parsed
# from projects_json. frozen dataclass + mutable default -> field(default_factory=dict)
# (a bare {} default raises ValueError). Empty dict = no override (old records work).
agent_models: dict = field(default_factory=dict)
agent_efforts: dict = field(default_factory=dict)
# Built-in default registry (used when ORCH_PROJECTS_JSON is empty/invalid).
@@ -50,6 +55,23 @@ _DEFAULT_PROJECTS = [
]
def _coerce_str_map(value, idx, field_name) -> dict:
"""ORCH-41: coerce an optional projects_json sub-object into a {str: str} dict.
Missing / null -> {} (no override). A non-object value is logged and dropped so
one malformed entry can never brick the whole registry; non-string keys/values
are stringified for safety.
"""
if value is None:
return {}
if not isinstance(value, dict):
logger.error(
f"ORCH_PROJECTS_JSON[{idx}].{field_name} is not an object, ignoring"
)
return {}
return {str(k): str(v) for k, v in value.items()}
def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
"""Parse ORCH_PROJECTS_JSON. Returns None if empty/invalid (-> use default)."""
if not raw or not raw.strip():
@@ -75,6 +97,8 @@ def _parse_projects_json(raw: str) -> list[ProjectConfig] | None:
repo=str(item["repo"]),
work_item_prefix=str(item["work_item_prefix"]),
name=str(item.get("name", item["repo"])),
agent_models=_coerce_str_map(item.get("agent_models"), i, "agent_models"),
agent_efforts=_coerce_str_map(item.get("agent_efforts"), i, "agent_efforts"),
)
)
except KeyError as e:

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