Commit Graph
9 Commits
Author SHA1 Message Date
17de29545a [6788ce7f] Silent bug sweep: concurrency, state integrity, engine edge-cases, panel data freshness (#638)
* [943d8c4d] Frontend data freshness and approval-queue reliability audit (#631)

* [233a8b0f] WebSocket reconnect message-loss audit and fix (#625)

* [233a8b0f] fix(panel): add REST catch-up to useNotificationStream on WS reconnect

connection.ts has no message buffering/replay, so a notification published
while the CEO bell's socket was down (disconnected/reconnecting) was lost
forever instead of merely delayed. Add a reconnect-triggered GET
/notifications?unread_only=true catch-up folded into the existing
notification_id dedup so a notification delivered both via catch-up and
live WS is never double-counted, and make clearMessages drop the held
catch-up batch too. use-a2a-live.ts and use-rate-limit-websocket.ts were
audited and already have working reconnect-triggered REST fallbacks
(verified via a2a/page.tsx, rate-limit-banner.tsx, usage-overview-panel.tsx
and their existing F083 tests) so no fix was needed there.

* [233a8b0f] docs(panel): add comprehensive WebSocket hooks reference and reconnect architecture guide

Add panel/docs/frontend/hooks.md with full API reference for useWebSocket, useNotificationStream (with new REST catch-up behavior), useAgentStream, useA2ALiveStream, and useConnectionStatus. Include examples, best practices, and testing guidance.

Add panel/docs/architecture/websocket-reconnect.md documenting the message-loss mitigation pattern: Strategy 1 (REST catch-up for events, used by useNotificationStream) and Strategy 2 (REST invalidation for state, used by A2A/rate-limit consumers), plus the dedup logic ensuring no notification is double-counted on reconnect.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [d5315683] fix(frontend): add distinct toast feedback for silently-swallowed x-post and release-proposal statuses, plus regression tests for all 4 approval queues (#626)

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [cd953838] Data-hook null-guard audit and API client 429 retry-by-method fix (#630)

* [cd953838] fix(panel): gate 429 retry by HTTP method, add hook null-guard regression tests

* [cd953838] chore(conventions): waive test-fixture wrapper in hooks null-guard test

* [cd953838] docs(frontend): document API rate-limit retry behavior and null-guard audit results

Added `docs/frontend/api-rate-limiting.md` to document the 429 retry strategy: GET/PUT auto-retry, POST/PATCH/DELETE require X-Idempotency-Key header. Updated `docs/frontend/hooks.md` to confirm the data-hook null-guard audit found all hooks already have correct `enabled` guards and include a regression test suite for the board-review poll on/off behavior and enabled-guard assertions.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [4534c71a] Backend concurrency, state-machine, and engine audit (#634)

* [41de844a] fix(lifecycle): sync CLAIM_RULES with runtime + clear stale claimant on PM hand-off (#627)

Two confirmed state-machine gaps found while auditing lifecycle.py,
task_lifecycle.py, the _ESCALATABLE_TO_BLOCKED bypass, and every
_REVIEW_QUEUE_STATES entry point:

- lifecycle.py's CLAIM_RULES/claim-ActionSpec/StatusTransition table
  did not grant CELL_PM/MAIN_PM re-claim of AWAITING_PM_REVIEW even
  though task.py's runtime _ROLE_CLAIM_STATUSES already granted it
  and claimed the spec agreed -- the two tables had silently drifted,
  breaking i_will_plan re-claim on an awaiting_pm_review task.

- docs_complete's _maybe_advance_to_pm_review pre-assigns a specific
  owning PM via assigned_to but left claimed_by/active_claimant_id
  pointing at the outgoing documenter, unlike every sibling transition
  into a review-queue state. A stale active_claimant_id makes
  content_actions.py's _active_claim_violation wrongly reject the
  newly-assigned PM's own content writes before it formally claims.
  Reassign claimed_by + active_claimant_id to the owning PM alongside
  assigned_to.

Adds a regression test asserting the documenter's stale claim does not
survive the docs_complete -> awaiting_pm_review hand-off.

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>

* [0c46f666] Engine dedup race + sequencing.py edge-case audit (#628)

* [0c46f666] fix(sequencing): dedup race audit + collision-edge fallback bug

Audited the list-open-then-originate dedup pattern across six engines:
RoadmapEngine, XEngine.run_cycle, DepUpdateEngine, and CIWatchEngine each
run inside exactly one sequential orchestrator-loop asyncio task (no other
call site invokes run_cycle), so they cannot race with themselves; their
in-cycle dedup sets/keys are correctly built before any commit. SelfHealEngine
is the same shape. VideoEngine.open_video_task is genuinely different: it is
reachable from the release-publish hook, the feature-spotlight hook, and the
on-demand POST /video/request route, so two overlapping calls for the same
occasion can both pass the "no open task yet" check before either commits.
Fixed by wrapping the check+insert in a short-lived Redis mutex (reusing
HeartbeatMutex) keyed by occasion, mirroring XPostService's existing
lock pattern, with a regression test proving only one of two concurrent
calls creates a task.

Verified ReleaseExecutor's half-landed retry path (release_commit_sha):
apply_version_bumps and write_changelog_entry both run as uncommitted
working-tree edits before commit_and_push's single `git add -A` + commit,
so a bumped-version-without-changelog state can never reach origin (and
therefore can never be observed by a fresh retry clone) - confirmed correct
with a real-git-repo regression test, no fix needed.

Fixed sequencing.py's dev_task_collision_edges: the `if edges: return edges`
short-circuit dropped the same-assignee-lane fallback entirely whenever ANY
surfaced sibling pair produced a collision edge, even for a completely
unrelated same-assignee pair with no declared surface. Now the fallback
always runs, skipping only pairs the analyzer already ordered (so the two
mechanisms can never disagree on direction for the same pair).

Verified sequencing.py rule 3 (all-shared batch generates no edges): correct
by inspection (_shared_last_edges skips every pair when both are shared) and
confirmed with a regression test - no fix needed.

* [0c46f666] docs(reference): concurrency audit summary - engine races, fixes, verified patterns

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [8f7f167a] Redis mutex pre-lock write audit (#629)

* [8f7f167a] Redis mutex pre-lock write audit: add cross-session regression test for XPostService.approve

Audited x_post_service.py, video_post_service.py, release_proposal.py, and
heartbeat_mutex.py for the pre-lock DB-write anti-pattern (a session write
that happens before the SET NX / HeartbeatMutex acquire returns a token,
letting a losing racer's stale write clobber a winner's committed state).

XPostService.approve, VideoPostService.approve, and
ReleaseProposalService.approve/reject already implement the correct
validate-pure-pre-lock, apply-under-lock pattern (the XPostService fix
already shipped per CHANGELOG.md: "X edited_body write deferred into the
single-flight lock (M5)"). HeartbeatMutex holds no AsyncSession at all, so
the anti-pattern is structurally inapplicable there.

Adds a genuine cross-session concurrency regression test to
test_x_post_service.py (a real second DB connection, not an in-process
mock) mirroring VideoPostService's existing cross-session test, proving a
concurrently-committed post survives and the CEO's edited body never lands
on the just-posted row.

* [8f7f167a] Remove redundant inline comments flagged by QA in cross-session regression test

Both comments restated what the surrounding docstrings already say
explicitly, per QA findings F-dbadd8f0 (line 294) and F-27ac051e (line
631) — no behavior change, tests re-verified green against a sandbox
Postgres.

* [8f7f167a] Remove inline trailing comments flagged by QA (correct file this time)

QA findings F-e6f3e6a6 and F-24189858 cited tests/unit/services/
test_x_post_service.py:294 and :631 across 5 revision rounds, but that
file never contained the flagged comment text — a repo-wide grep for
the exact quoted strings shows both comments actually live in the
mirrored tests/unit/services/test_video_post_service.py file, in its
own cross-session concurrency regression tests (the caption-edit and
tiktok-skip tests). Removed both there:
- "# externally visible to the "concurrent" session below" on the
  db_session.commit() call
- "# never attempted without credentials" on the tiktok_poster.calls
  assertion

Both restated what the surrounding docstrings/test names already say;
no behavior change. Verified with the full make quality gate against a
sandbox Postgres/Redis: 13,717 passed, 94.41% coverage, clean except
one pre-existing unrelated failure in tests/unit/api/test_cloud_auth.py
::test_login_route_parses_oauth2_form_not_query_params, which connects
to the app's default localhost:5432 Postgres (not the db_session
sandbox fixture) and is unreachable in this sandboxed environment —
structurally unrelated to the auth subsystem this task never touches.

* [8f7f167a] Redis mutex pre-lock write audit (round 7): add cross-session regression tests for reject() lock protection

Round-7 QA findings F-7eb9fbcb, F-06f39a2e, and F-4d56e49b claim
XPostService.reject(), ReleaseProposalService.reject(), and
release_executor._await_proc() lack lock protection / a CancelledError
handler — but their cited line ranges (255-267, 429-454, 241-257)
describe a pre-fix, shorter version of these functions that predates
commit fb293a787d, already on this branch. At current HEAD:

- x_post_service.py reject() (lines 275-299) acquires _LOCK_PREFIX,
  re-reads under the lock, applies markers.set_x_reject_reason() +
  CANCELLED only inside the critical section, releases in finally.
- release_proposal.py reject() (lines 460-486) does the identical
  dance with _RELEASE_LOCK_PREFIX.
- release_executor.py _await_proc() (lines 257-265) already has an
  `except asyncio.CancelledError` block that kills + reaps the child
  and re-raises, mirroring the TimeoutError handler, with an existing
  dedicated regression test
  (test_await_proc_kills_child_on_outer_cancellation).

The one genuine gap: neither reject() path had a cross-session
(real second DB connection, not an in-process mock) regression test
proving the in-lock re-read catches a concurrent approve/publish that
completes mid-lock-wait — only approve() had one. Added
test_reject_concurrent_approve_completes_during_lock_wait to both
test_x_post_service.py and test_release_proposal_status_guards.py,
mirroring the existing approve() cross-session test: a second engine
commits COMPLETED between reject's pre-lock read and lock acquisition,
and the test asserts the CANCELLED write / reject-reason marker never
lands on the just-completed row.

No production code changed — verified via 103 targeted tests green
against a sandbox Postgres/Redis, plus `make -o sync gate` clean.

* [8f7f167a] Regenerate stale lifecycle artifacts (restore auditor waive_finding)

foundation-check was the only failing gate: the committed lifecycle artifacts
were missing the auditor's waive_finding verb that the lifecycle source
defines, so make quality regenerated them and failed on the diff — nothing to
do with the mutex fix (which passes ruff/mypy/tests/coverage/bandit clean).
make lifecycle restores the drift; this is what the 8 revision rounds kept
missing.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [d615f2e3] fix(tests): sync stale CLAIM_RULES pinning assertions with lifecycle.py (#635)

test_claim_rules_match_pre_gateway_table still asserted the pre-audit
two-member frozenset for CELL_PM/MAIN_PM claim rules. CLAIM_RULES in
lifecycle.py already grants both roles claim rights on
Status.AWAITING_PM_REVIEW (added by the state-machine exhaustiveness
audit) so a PM can re-claim its own review-queue task after a respawn.
Updated both assertions to include AWAITING_PM_REVIEW, matching the
actual dict. Grepped the repo for sibling stale copies of the old
literal; found none beyond this test.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [3c4e7a35] fix(quality-gate): reflow CONCURRENCY_AUDIT.md and stub occasion lock in video tests (#636)

Root cause: PR #634's CI failed at the markdown-reflow check (make quality
Makefile:285) on CONCURRENCY_AUDIT.md — a hard-wrapped audit doc left over
from the merged "Engine dedup race + sequencing.py edge-case audit" unit
(PR #628). Fixed with `make reflow-docs` (the exact remedy the CI output
itself named).

Running the full local `make quality` (with a sandbox Postgres/Redis to
get past DB-gated skips) surfaced a second real regression from that same
PR #628 unit: it added a Redis-backed HeartbeatMutex occasion lock to
VideoEngine.open_video_task, but two pre-existing test files
(tests/unit/runtime/test_video_render_loop.py and
tests/integration/test_video_routes.py) call open_video_task without
stubbing that lock, so they failed closed against the suite's
deliberately-unreachable test Redis (_no_live_redis). Fixed by applying
the same lock-stub pattern tests/unit/services/test_video_engine.py
already uses for its own occasion-lock tests: an autouse HeartbeatMutex
stand-in fixture in test_video_render_loop.py, and wrapping the two
route-level video-request tests in test_video_routes.py with the file's
existing _LOCKED patch pair (already used by every other lock-dependent
test in that file).

The one remaining local failure,
test_cloud_auth.py::test_login_route_parses_oauth2_form_not_query_params,
is a pre-existing environment gap unrelated to this branch: it needs a
real Postgres reachable at localhost:5432 (which .github/workflows/ci.yml
provides as a service container) but this dev sandbox has no such binding
— confirmed unrelated to any of the four merged audit units.

make quality now passes clean: 13729 passed, 0 regressions, 94.49% coverage.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [ddc8121f] regenerate lifecycle artifacts for awaiting_pm_review claim rules and waive_finding intent (#637)

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* fix(sequencing): drop lane-fallback edges that would cycle against analyzer edges

The dev-task collision fallback unioned the analyzer's authoritative edges
with same-assignee lane edges, deduping only the direct pair. A lane chain
through an unsurfaced middle sibling could still contradict an analyzer edge
transitively (the shared-last migration order inverts plain priority order),
closing a 3-cycle that made add_dependency raise ConflictError and wedged
every later delegate to that parent. Fallback edges are now accepted only
when they can't close a cycle against the edges already kept; a regression
test reproduces the exact scenario.

Also strip pre-merge cruft: remove the root CONCURRENCY_AUDIT.md working
report, delete the near-duplicate websocket-reconnect.md doc, fix the stale
a2a/page.tsx doc citation, correct the api-rate-limiting doc to state
idempotency-key retry is unimplemented, and fix two lifecycle.py comments
that referenced a guard function which never existed.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 08:11:28 +02:00
cfde4369b1 Token optimization levers — claim-scoped briefing, payload caps, role-scoped optimal, notification-spawn cooldown (#292)
* feat(gateway): claim-scoped context briefing — heavy sections only on context-acquisition verbs

* feat(gateway): cap unbounded LLM-facing payloads — embedded diffs, notification bodies, handoff journal content, north star

* feat(mcp): role-scope the optimal server's tool groups; index management becomes dev/test-only

* feat(mcp): cap per-result content on kb/error/learning search, mentor sources, rag citations

* refactor(gateway): extract heavy-briefing sections + clip helper to keep xenon ranks

* feat(orchestrator): cross-tick cooldown for notification-triggered spawns

* feat(usage,orchestrator): scope spawn-waste to anthropic sessions; cap agent Bash output via settings env

* docs: claim-scoped briefing, payload caps, optimal role-scoping, notification-spawn cooldown

* test(mcp): type the mixed-item cap fixture explicitly

* fix(orchestrator): lazy-init the notification-spawn cooldown store

* fix(lifecycle): admin-override claim reconciliation + PM request_changes verb (S6 postmortem B3+B4)

B3 — admin_set_status now reconciles claim ownership when leaving BLOCKED:
review/queue targets clear claimed_by/claimed_at/active_claimant_id and
consume the pre-block snapshot (a stale escalation claim was stranding the
next claimant: give_me_work handed the task out while note() bounced
not_authorized — the live b8fe0494 wedge). The pending/in_progress restore
path also syncs active_claimant_id, and a REST PATCH unassign releases the
claim with it.

B4 — new PM verb request_changes: awaiting_pm_review -> needs_revision with
concrete issues. The PM previously had no reject at merge review (only
complete/escalate), so an AC/scope violation looped i_am_blocked->escalate
4x live. Full vertical: lifecycle transition + ActionSpec + IntentSpec,
TaskService.request_changes (routes like a QA fail — original dev for a
leaf, revision PM for assembled; issues appended to dev_notes), verb-runner
compose, choreographer verb (spec gate + non-empty issues + soup check +
a2a delivery of the reject reason), HTTP routes on both PM flows, MCP tool,
journal:decision tracing, PM prompts, regenerated lifecycle artifacts.

* fix(panel): stop scorecard fetches for fallback-roster placeholder ids

useAgents() serves the static AGENT_ROSTER (ids "1".."22") while agent
definitions load; the Scorecards tab fetched a member scorecard per row
immediately, firing 22 guaranteed-422 requests per refetch cycle. Through
the browser's per-origin connection limit those queued every metrics-page
query behind them (~10s of skeletons on every tab). Gate the fetch on a
real member id (agent UUID or the "ceo" alias).

* Upgraded uv.lock

* fix(sequencing): declared deps become real edges + full loop-breaker coverage + assembled-branch freshness (S6 postmortem B1/B2/B6 + breaker)

B1a — code delegations REQUIRE a collision surface: new TASK_AT_DELEGATE
completeness spec (conditional FieldRequirement, when=('task_type','code'))
enforced at the gateway delegate gate. A no-surface code sibling is
'parallel to everything' by analyzer design, which is how two devs ran the
CEO's explicitly-ordered work out of order (f3e1afc5: seq#1 started before
seq#0, zero dependency edges). PM prompts updated; REST/manual creation
(TASK_AT_CREATE) unchanged.

B1b — the CEO's declared 'Depends on' lists become real edges: DraftSurface
gains declared_depends_on; SequencingService.analyze unions declared edges
(validated: self/out-of-range rejected) with the derived collision rules,
cycle-checked by the existing toposort. confirm_live_batch/preview_batch
map each draft's depends_on through (string indices coerced); intake tool
doc + prompter role prompt instruct verbatim copying. The live S6 root got
1 of its 3 declared in-batch edges and started alongside still-running R3.

Breaker coverage — the progress-aware respawn circuit breaker
(_pm_respawn_should_gate: strike counting, status-advance reset,
tracing-gap budget, DB durability, one-shot CEO notification) was consulted
by only 3 spawn paths; the doc/QA/dev/PR-review/PR-gate/revision/board
paths spawned unguarded at fixed cadence (the 26-respawn fe-doc loop,
~$7.20). Now consulted at every task-keyed spawn site (14 total).

B2 — assembled-branch freshness: submit_up/submit_root auto-sync the
assembled branch when it has fallen behind its base (children are terminal
at submit time, so the rebase is safe; master is never written). A rebase
conflict is a hard reject naming the files instead of a blind re-review —
kills the needs_revision↔awaiting_pr_review ping-pong of re-submitting a
stale head. Leaf i_am_done already had the behind-base gate; claim-time
fetch-fresh cut already existed.

B6 — documenter revision-pass loop: the awaiting_documentation bail
rejections (i_am_blocked/unclaim) now name the actual exit (i_documented
re-affirm) and the documenter prompt gets an explicit revision-pass rule.

* fix(orchestration): assembly-integrity gate + dispatcher heartbeat (incidents #11, #1)

Assembly integrity — submit_up/submit_root refuse when a completed child's
commits are not patch-present in the assembled branch (git cherry —
rebase-safe; branch pruned after merge or any git error fails open). Live
incident #11: a completed revert subtask's merge was lost from the cell
branch and the review gate re-flagged the exact violation the revert fixed,
spawning another revision cycle.

Dispatcher heartbeat — a dispatcher.alive audit row every 5 minutes from
the dispatch loop. The 2026-07-01 outage was 4h25m of fleet-wide silence
with no way to distinguish 'loop dead' from 'no work'; the loop's stdout
died with the container while audit_log survives. CHANGELOG for tonight's
full sweep included.

* style: ruff format for the orchestration sweep

* refactor(gateway): fold the assembled-submit guards + trim complexity under the xenon gate

_assembled_submit_guards combines the #11 integrity check and B2 freshen for
submit_up/submit_root; lifecycle's invalid-source remediate and git's
per-child cherry probe extracted into helpers. Test harnesses built via
__new__ stub the respawn tracker (the breaker now runs on their paths).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-02 05:46:01 +02:00
df87fcf059 Chore/logical gaps element sweep fixes (#287)
* [sweep] lifecycle: 6 confirmed gaps fixed (cancel-ceo-gate, claim_pr_review gate, needs_team_match, valid_next_verbs narrowing, pr_reviewer unclaim, complete side_effect ordering)

* [chore] logical-gaps: route-layer force gate + privileged-field gate + pre-task audit attribution

tasks.py (5 gaps):
- _HATCH_OVERRIDE_STATES expanded to 7: a privileged PATCH INTO a gate
  state (completed/cancelled/awaiting_{qa,documentation,pr_review,
  pm_review,ceo_approval}) now requires explicit force — the panel hatch
  is no longer a quiet click that drops a task into/out of a human gate.
- _RESURRECT_SOURCE_STATES: a privileged PATCH OUT of a terminal status
  (completed/cancelled) resurrects finished work and likewise requires
  force, audited as an override.
- _PRIVILEGED_UPDATE_FIELDS gate: a bare task owner (UPDATE_OWN, no
  ASSIGN) cannot self-reassign / re-team / re-parent / re-depend /
  re-block / rewrite-plan / re-project its task — those structural fields
  are PM-gated; the REST surface must not bypass the verb-layer's
  reassign/delegate/triage gate. A 403 names the touched fields + the
  verb to use instead.
- pre-task create denial: a role that cannot create tasks is now logged
  via log_task_creation_denial (distinct task_creation target_type +
  attempted payload) instead of a 'N/A' task_id that coerced to NULL and
  left the role-escalation attempt unattributable.

audit.py:
- split log_task_action_denial (5-param, under PLR0913) from
  log_task_creation_denial (4-param) — the create path has no task_id;
  the non-UUID sentinel (N/A) is preserved in details[target_id_raw]
  rather than dropped to a NULL target_id indistinguishable from any
  other NULL-target denial.

tests:
- test_tasks_routes.py: parametrized admin-override gate (force
  required for gate + terminal states, force succeeds).
- test_tasks_route_privileged_fields.py: dev owner 403 on
  assigned_to/team/parent_task_id, 200 on dev-facing description.
- test_audit.py: pre-task attribution via log_task_creation_denial +
  non-UUID sentinel preservation.

* [chore] logical-gaps: kanban board column coverage + status-class fixes (6 gaps)

models/kanban.py:
- DEV_COLUMNS: cover all 15 lifecycle statuses (was 7; dropped BACKLOG,
  PAUSED, VERIFYING, NEEDS_REVISION, AWAITING_PR_REVIEW, AWAITING_PM_REVIEW,
  AWAITING_CEO_APPROVAL, CANCELLED). A dev whose task bounced to
  needs_revision or sits in a gate used to see their own task vanish.
- PM_COLUMNS: add the gate/revision/paused/cancelled/backlog columns so the
  cell PM sees the QA->docs->PR-review->PM-review->CEO chain on its board.
- QA_COLUMNS: drop the 'In Review'->VERIFYING mapping. VERIFYING is the dev's
  self-verification (task still with the dev, not with QA); it misrepresented
  dev mid-verification as active QA work.

services/kanban.py:
- _build_flat_board: add an 'Other' fallback column for any task whose status
  matches no configured column, so total_cards == sum(card_count) and no card
  is built-then-silently-dropped (the vanished-card leak).
- get_qa_board: drop VERIFYING from qa_statuses (consistent with the column
  change).
- get_documenter_board: scope to task_type=documentation so a dev IN_PROGRESS
  code task sharing the cell team no longer appears under 'Gathering'.
- get_main_pm_board_flat: widen the status filter to include PENDING/CLAIMED/
  COMPLETED and route those to the incoming/distributed/done columns, which
  were structurally always empty under the in-flight-only filter.

tests/integration/test_kanban_service.py: parametrized coverage of every
dropped dev status, PM gate/revision states, QA excludes VERIFYING,
documenter excludes dev code tasks, flat Main PM incoming/distributed/done
populated, and the 'Other' fallback invariant.

* [chore] logical-gaps: lifecycle-enforcement validators + status-class fixes (5 gaps)

enforcement/task_lifecycle.py:
- drop the spurious VERIFYING->awaiting_documentation legacy edge. The
  canonical exit is submit_qa -> awaiting_qa -> (qa_pass) ->
  awaiting_documentation; the direct edge bypassed the entire QA review hop
  (ungated — no role gate existed for it).
- is_waiting_state: add awaiting_pr_review. The PR-review gate parks the PM on
  the reviewer; it is a waiting state. The hard-coded set was never updated
  when AWAITING_PR_REVIEW was added to the enum, so the gate status was
  miscategorized as active.

foundation/_validate_lifecycle.py:
- _check_status_enum_coverage: replace the tautology (STATUS_GRAPH keys every
  Status by construction) with a real bidirectional check — every non-terminal
  Status is the source of a transition (catches orphan states), and every
  source/target referenced is a real Status member (catches stray-string
  targets).
- _check_terminal_exits: split the {COMPLETED, CANCELLED} reachability into a
  COMPLETED-path requirement + a cancel-exit requirement. The cancel fan-out
  made the old check structurally trivial — a status whose sole exit was cancel
  passed with no real forward completion path.
- _check_status_enum_parity (new, registered): cross-check spec.Status against
  models.base.TaskStatus at import so the ORM column type and the lifecycle
  map cannot drift (TaskType had this guard; Status did not).

tests: verifying->awaiting_documentation rejected, self-fail preserved,
awaiting_pr_review is waiting, mutually-disjoint classification invariant,
status enum parity, stray-string-target / orphan-source / cancel-only-exit
validator rejections.

* [chore] logical-gaps: stream-bus poison-pill ACK + dead-letter, periodic reclaim, cancelled-handler marker cleanup (3 gaps)

stream_bus.py:
- _handle_message isolates Event.from_json in its own try/except; an
  undecodable payload (unknown EventType, bad UUID/timestamp, malformed
  JSON) is dead-lettered then ACKed instead of falling through to the
  broad except that only logged — a poison pill stayed pending forever
  and re-failed on every reclaim. (gap: stream-bus-malformed-event-poison-pill)
- _reclaim_loop spawned alongside _listen_loop in start_listening (cancelled
  in disconnect). XREADGROUP '>' delivers only NEW messages, so a runtime
  handler failure left its message pending and unretried until a restart;
  the loop re-runs recover_pending every 60s so the idempotency-guarded
  replay actually fires. (gap: stream-bus-no-runtime-reclaim-loop)
- _run_handler_guarded marker cleanup catches BaseException so a handler
  cancelled mid-flight (asyncio.CancelledError is BaseException-derived
  since 3.8) clears its SET-NX marker; otherwise the guard suppressed the
  very redelivery that would complete the work. (gap: stream-bus-cancelled-
  handler-keeps-idempotency-marker)

TDD: 4 red->green tests in tests/unit/events/test_bus.py.

* [chore] logical-gaps: verb_runner trailing-None side-effect guard + actor_agent_id threading (3 gaps)

_verb_runner.py:
- run_intent skips the side_effects loop when a TRAILING composed action
  returned None (its source-status check failed under a concurrent
  transition). Previously the loop ran unconditionally on the None task
  and _do_push_branch(None)/_do_pr_merge(None) crashed with a
  NoneType AttributeError, turning the clean INVALID_STATE the
  entry/intermediate guards give into a 500/respawn loop. The trailing
  None now flows to the caller's `if task is None` handler. Latent today
  (no shipped intent has both a None-capable compose and trailing
  side_effects) but the runner is generic. (gap: runner-side-effects-fire-
  on-trailing-none-task)
- _do_push_branch / _do_create_pr / _do_create_root_pr forward
  actor_agent_id=agent.id into git_service (push_branch / create_pr),
  matching _do_pr_merge. Without it, a verb on a task whose assigned_to
  was cleared before the side effect falls through to created_by and
  pushes from / opens a PR against the wrong workspace.
  (gap: side-effect-handlers-drop-actor-agent-id)
- _do_escalate_to_ceo forwards actor_agent_id=agent.id so the
  awaiting_ceo_approval audit row attributes to the specific PM/Board
  agent. (gap: do-escalate-to-ceo-drops-actor-agent-id)

task.py: escalate_to_ceo gains actor_agent_id param, passed as
audit_agent_id to _validate_and_set_status and recorded as
escalated_by_agent_id in the event payload + log. escalate_to_ceo_for_agent
forwards agent.agent_id.

_impl.py: the main_pm complete->escalate path forwards
actor_agent_id=main_pm_agent_id.

TDD: 5 red->green tests (synthetic trailing-None intent, actor forwarding
for push_branch/create_pr/create_root_pr/escalate_to_ceo) + real-DB audit
test asserting the awaiting_ceo_approval row carries the actor UUID.
Updated 3 board escalate_to_ceo tests to assert the forwarded actor.

* [B-REL] release executor: idempotent half-landed retry + commit-scoped CI + decoupled workflow

Three confirmed gaps in the release fail-closed pipeline (#87/#318/#402):

#87 publish_failed retry duplicates changelog: execute() only short-circuits
on an existing tag. A publish_failed outcome (commit pushed + CI green, no
tag) left no tag, so a retry re-ran apply_version_bumps + write_changelog_entry
(re-inserting the entry above the already-present heading -> duplicate) and
commit_and_push (a second chore(release) commit). Add ReleaseOps
.release_commit_sha(version) detecting a prior release commit on the branch
(clone already at the target version); when present, skip the bump/changelog/
gate/commit pipeline and rejoin the shared CI -> publish tail on the existing
commit. No second commit, no duplicate entry.

#318 wait_for_ci polls branch-latest, not the release commit: a later push to
master during the ~40min wait made the latest run's head_sha != the release
sha forever, exhausting _CI_MAX_POLLS -> false ci_failed on a release whose
own CI was green. Thread head_sha through get_latest_ci_conclusion /
_fetch_latest_ci_run (GitHub actions/runs?head_sha=) so the gate polls the
release commit's own run; a concurrent push can no longer mask it.

#402 release CI gate reuses self_heal_ci_workflow: that setting documents an
empty-string mode for single-workflow repos which, inherited here, degraded
the fail-closed gate to the all-workflows mode git.py itself flags as
unreliable. Add release_ci_workflow (default ci.yml) and _resolve_release_
ci_workflow(); the release gate always resolves a NAMED workflow, never None.

Refactor: bundle the CI-fetch per-project inputs into a _CiRunQuery dataclass
so _fetch_latest_ci_run stays under the arg-count gate; unify the half-landed
path into execute's shared tail (drops a separate _publish_existing, one
return path). TDD red->green; ruff/mypy clean.

* [chore] logical-gaps: a2a service hierarchy gate (typed, unconditional) + persist skill on message row (3 gaps)

create_a2a_notification gated A2A hierarchy only when both ends resolved
(`if from_agent and target_agent:`), so an unattributed (from_agent falsy)
or unresolvable-target request slipped past the hierarchy matrix and
dispatched with from_agent='unknown' / to_agent='' — and a denial came back
as a bare ValueError indistinguishable from the missing-task_id ValueError.
Require both ends present, then validate via the shared typed
validate_a2a_access path (A2AAccessDeniedError + route_hint) so the legacy
notification surface enforces the same who-may-talk-to-whom invariant as the
conversation path.

send() accepts skill= and the gateway callers (qa/doc/pr_gate) pass it
expecting the receiver to learn which capability the message is about, but
send_chat_message never read it from options — silently dropped. Persist a
nullable skill column (migration 054) on a2a_messages, wire it through
send_chat_message + _msg_to_model + the A2AChatMessage model, and fix the
send() docstring (it claimed 'recorded in message metadata').

TDD: 4 red→green (skill recorded on message + surfaces in inbox; permission
denied raises typed A2AAccessDeniedError with route_hint; self-A2A raises
typed; missing from_agent raises instead of silent dispatch). 103 a2a
integration tests green; ruff/mypy clean; migration 054 verified
upgrade/downgrade on throwaway PG.

* [chore] logical-gaps: release-proposal already_published closes proposal + heartbeat-lock-loss cancels execute (2 gaps)

approve() closed the proposal only on status=='published'. A retry that finds
the tag already shipped returns 'already_published' (is_already_published),
so if a prior publish's route commit failed / HTTP 504'd, the proposal stayed
non-terminal forever — every retry returned already_published and never
closed it; only a manual cancel unstuck it. Close on both published and
already_published: the release shipped either way.

_heartbeat_loop returned silently when the lock was no longer owned (a >TTL
Redis outage let the mutex expire mid-execute), leaving executor.execute
running UNGUARDED — a concurrent approve (once Redis returns) could then
acquire the lock and _prepare_release_clone rm -rf the in-flight shared
release clone while the first execute was still mid-run_gate, re-opening the
very rm -rf-clone race the mutex+heartbeat exist to prevent. Run execute as a
task; on lock-loss the heartbeat sets a flag and cancels it, and approve()
turns the CancelledError into a structured 'lock_lost' result (an external
cancellation of approve itself still propagates — distinguished by the flag).

TDD: 2 red→green (already_published → COMPLETED not wedged; heartbeat lock-loss
→ lock_lost + execute cancelled, proposal not completed). 8 concurrency tests
green; ruff/mypy clean.

* [chore] logical-gaps: release approve async dispatch (202) — kill the 40min synchronous HTTP 504

The approve route ran the whole fail-closed execute inline: clone(600s) +
gate(1800s) + CI poll(2400s) + publish(300s) ≈ up to 85min worst case. nginx
(the single :3000 entry point, ~60s read timeout) 504'd long before it
finished, so the CEO's approve always appeared to fail even when the release
succeeded server-side — the structured ReleaseResult was unreachable over the
wire. dispatch_approve spawns the execute in a background task with a fresh
session (built from the request session's engine) and the route returns 202
'accepted' immediately; _INFLIGHT_APPROVES tracks the dispatched task for
observability (self-cleans via done-callback; the Redis mutex still refuses a
double-execute on a second click). The panel already polls GET /proposal every
30s, so it observes the final status (COMPLETED on published/already_published,
else the proposal stays open for retry); the card's approve toast now treats
'accepted' as an info 'dispatched, running in the background' instead of the
old 'Release halted' warning.

TDD: 2 route tests red→green (approve returns 202 'accepted' + the proposal
transitions to COMPLETED / stays PENDING once the background faked execute
completes; the dispatched task is awaited while the executor patch is live).
83 release tests green; ruff/mypy clean; panel typecheck+lint+format+test
green.

* [chore] mcp-servers: normalize exception bodies to Envelope + lift task_id/correlation_id on circuit_open (#232 #359 #57)

flow_server/do_server: the non-404 JSON path returned exception-handler bodies
raw (dict `error` from roboco/generic/http exception handlers, or a 422
`detail` list) — neither is the Envelope wire format the agent is prompted to
trust (string error kind + message + remediate + missing), so on any
service/validation failure the agent got no remediate and flailed until the
breaker tripped. _normalize_exception_envelope lifts the body into a real
Envelope (code -> counted string kind via _classify_dict_error_code, NOT_FOUND
-> not_found, message lifted, remediate synthesized, missing=[]; 422 -> incomplete_input with the validation detail preserved). The synthesized
envelope still flows through the breaker so a 500/422 storm trips it.

_record_and_check_circuit: the circuit_open substitution dropped task_id /
correlation_id from the top level (the SDK's envelope omits them); lift them
from the original rejection so the agent's envelope contract and ops audit-join
of the trip event still work, not just nested in inner.

intake_server._post_event: capture the relay response body under `detail` on
non-success so the grok intake agent gets the real reason (e.g. 'session not in
MegaTask scope' on a 422) instead of an opaque http_422 token with no
remediation.

TDD red->green; ruff + mypy clean; 157 mcp/SDK-breaker tests pass.

* [chore] a2a-routes: authenticate send_message responder + gate cancel task (PM-only) (#116 #423)

send_message took the responder identity from a client-supplied
metadata.from_agent, so any caller could spoof anyone (e.g.
from_agent='ceo') in the task's notes and in the spawn/notification
routed back to the original requester. Stamp the authenticated caller's
slug as the responder instead (CurrentAgentContext).

cancel_task was ungated: no auth dependency and no role check, so any
agent (or any caller) could cancel a task the lifecycle rule reserves to
PM roles (Any -> cancelled: PM roles only) — and the cascade-cancel of
all non-terminal descendants ran with a hardcoded cell_pm role and no
recorded actor. Add require_any_authenticated_agent + a PM-or-above gate,
and thread the authenticated role (into the cascade role gate) and slug
(into the cancellation note) into A2AService.cancel_task.

Tests: send_message ignores a spoofed from_agent and records the
authenticated slug; cancel rejects a developer (403) and a missing auth
header; a PM cancel threads role + slug into the service; the pre-existing
cancel success/already-terminal/not-found tests now run under a PM context
(the success test's body was missing the A2A 'name' field and false-passed
on a 422 — now genuine).

* [chore] work-session-routes: ownership check on mutating routes + stamp merge_pr merged_by from auth (#158 #271)

Every mutating work-session route keyed off session_id alone after the
role gate, so any developer could commit into / abandon / complete a
peer's active session (breaking the single-active-WorkSession invariant
and stranding that task) and any PM could merge any cell's PR — the REST
surface bypassed the verb layer's active-claimant gate entirely. Add a
shared _assert_ownership guard: dev ops require session.agent_id to be
the caller; PM merge_pr requires a cell PM to own the session's task cell
(main PM / CEO / board coordinate every cell), 404 for a missing session.

merge_pr took merged_by from the request body, so any PM could record a
PR merge under another agent's id, corrupting the merge audit trail the
completion/CEO-approval chain and metrics rely on. Drop the body param
and stamp the authenticated caller's agent_id as merged_by (the
MergePRRequest schema is gone with it).

Tests: a second dev's token hitting a peer's /commits and /abandon -> 403
(session left active); a foreign-cell PM -> 403, same-cell PM -> 200; a
spoofed body merged_by is ignored and the persisted row records the PM.

* [chore] ci-watch/dep-update dedupe: normalize git_url + treat empty-string workflow as default (#148 #1267)

The per-repo open-task dedupe filtered ProjectTable.git_url == git_url
(exact), while the orchestrator collapses its poll set by repo_key
(lower / strip trailing '/' / drop '.git'). Two projects whose git_url
differs only by those accidentals (a monorepo's cell-projects, or a
re-registered canonical project) defeated the one-open-task-per-repo
invariant and opened duplicate fix / dep-update tasks. Extract
roboco.utils.converters.repo_key as the single source and match the
dedupe query on its SQL mirror (regexp_replace(rtrim(lower(...)))).

The ci_watch (git_url, workflow) dedupe used func.coalesce(ci_watch_workflow,
default), but SQL COALESCE only substitutes for NULL — a project saved with
ci_watch_workflow='' (reachable via panel/API) yielded coalesce('', default)
= '' != default, so the DB diverged from the engine/orchestrator (which
collapse '' to the default via Python truthiness) and opened a duplicate
fix task every red cycle. Wrap with func.nullif(..., '') so an empty string
collapses to the default too.

Tests: a ''-workflow + NULL-workflow project on one repo dedupe to one task;
git_url accidentals (.git suffix / trailing slash) dedupe across both
ci_watch and dep_update. The orchestrator _repo_key now delegates to repo_key.

* [chore] admin_set_status: attribute the blocked-restore to the admin actor + emit override row (#2176)

admin_set_status taking a BLOCKED task to pending/in_progress with a
pre-block snapshot returned early via _apply_pre_block_restore, which
emitted its audit row with agent_role=None and audit_agent_id=restored_owner
(the pre-block dev) — the admin actor_id/actor_role were dropped entirely.
Because this branch runs with force=false (pending/in_progress aren't hatch
destinations), the distinguishing task.admin_override row (written only on
the non-restore path, gated by force) was never written, so an operator
could silently re-own a blocked task with no trace of who triggered it.

Thread actor_id/actor_role into _apply_pre_block_restore (admin_set_status
passes them with admin_override=True) so the transition audit row attributes
the re-owning to the admin, and emit a task.admin_override row (forced=False,
restore=True) on this branch independent of the force flag. The in-band
unblock(restore=True) path passes no actor and keeps the legacy attribution
(restored owner) with no override row.

Test: admin PATCH status=pending on a BLOCKED task with a snapshot attributes
every audit row to the admin (not the restored dev) and emits the override
row.

* [chore] converters: typed InvalidIdentifierError from require_uuid + log the orchestrator drop (#25)

require_uuid raised a bare ValueError('UUID value cannot be None'), so a
malformed/None identifier propagated as an opaque error callers either let
500 or broad-catch-and-silently-swallow — the orchestrator reaper call site
wrapped it in a bare except-Exception return with NO log, dropping a bad
task_id_str invisibly. Introduce InvalidIdentifierError(ValueError) and
raise it from require_uuid for both None and unparseable input; it stays a
ValueError subclass so existing except-ValueError / except-Exception callers
are unaffected, but typed so a caller can handle a bad identifier distinctly.
The reaper now catches the typed error, logs at warning, and no-ops — the
drop is visible instead of swallowed.

Tests: None and an unparseable string both raise InvalidIdentifierError; it
subclasses ValueError (back-comat).

* [sweep] notification_delivery: list_system_notifications over-fetch-then-slice for pending_ack_only

The SQL limit was applied before the post-fetch 'not fully acked' Python
filter. A window of newer fully-acked ack-required rows filled the limit
and masked older unacked notifications the operator still needs to act on
(the pending-ACK queue silently under-reported; a CEO-approval notification
could be hidden by newer already-acked noise). pending_ack_only now drops
the SQL limit, filters in Python, then slices to limit; the non-pending
branch keeps the SQL limit unchanged.

* [sweep] proactive: drop vestigial code-patterns surface from context package

Code indexing was removed, so _find_code_patterns always returned [] yet
build_context_package still called it, ContextPackage.code_patterns stayed
a live field, _build_summary advertised 'Found N code patterns', and
_count_items counted it — a permanently-empty slot the system claimed to
populate. The dead method, its call, the summary line, and the count
reference are removed. The code_patterns field itself is retained
(always-empty, serialized in to_dict and the optimal route response) for
API/schema back-compat, marked deprecated in its docstring.

* [sweep] migration 052: integration-test the task_cell_projects unique constraint

The UNIQUE(task_id, team) 'one project per cell per task' invariant was
only exercised through SimpleNamespace stubs that never touch a DB
session, so the real Postgres constraint was unverified. If it were
mis-declared or dropped, two same-team rows could coexist and
_resolve_subtask_project would non-deterministically return one, cutting
a subtask's branch/PR against the wrong repo. Adds an integration test
that inserts two same-(task_id, team) rows and asserts IntegrityError on
uq_task_cell_projects_task_team, plus a positive different-teams case.

* [sweep] pr_gate: classify MegaTask root-subtask as root so its root->master PR gets COMMENT (#608)

_post_gate_review_to_pr identified a root->master PR by absence of a
parent_task_id. A MegaTask root-subtask opens its own root->master PR into
the project's master (submit_root, parent='master') but carries
parent_task_id=umbrella, so is_root was False and the gate posted APPROVE
(pr_pass) / REQUEST_CHANGES (pr_fail) instead of COMMENT. The APPROVE could
satisfy a single-approval master branch-protection rule and let a non-CEO
merge via the GitHub UI before the CEO, against the documented invariant
that only the CEO acts on master. is_root now also covers
is_batch_root_subtask (batch_id set + parented); a non-batch cell-PM
coordination root keeps batch_id=None so it stays a cell->root PR
(APPROVE/REQUEST_CHANGES). Extends the _task test helper with a batch_id
kwarg.

* [sweep] enforcement: complete the status-class partition + coverage invariant (#247)

is_waiting_state already covered awaiting_pr_review (the primary fix), but
the doc's coverage invariant was missing: backlog and pending fell through
ALL three predicates (terminal/active/waiting), so a future enum addition
could silently land in no category. is_waiting_state now also covers
pending (waiting for a claim) and backlog (waiting on PM activation), so
is_terminal_state / is_active_state / is_waiting_state partition the whole
Status enum. Adds test_status_classification_covers_every_enum_member
asserting every Status member is classified by exactly one predicate, so
an enum addition that drifts the partition fails the build.

* [chore] test-suite: unblock the quality gate (mypy + 2 behavior fixes)

12 mypy errors across 5 test files: drop banned type:ignore comments
(lifecycle_spec monkeypatch uses cast(Any, ...); the ignores were unused),
wrap SQLAlchemy-typed ids with cast(UUID, ...) for AgentContext / WorkSession
args (AgentTable.id is Mapped[sqla UUID], not uuid.UUID), annotate **kw: Any,
and cast(Any, svc) for a method-assignment mock.

test_cancel_descendants_cascades_for_authorized_pm: the child was parked in
awaiting_ceo_approval, which the spec gates to CEO-only cancel
(lifecycle.py:378-389) — a cell_pm cascade correctly refuses it (the #103
refuse path). Use a PM-cancelable in_progress child so the positive-cascade
assertion holds; the refuse case is already covered by its sibling test.

test_a2a_message_auth: /message/send now resolves the authenticated
responder slug via get_agent_context (a DB lookup, #116). This is a DB-free
unit test of the token gate + route body, so stub get_agent_context in the
fixture — the gate (require_any_authenticated_agent) still runs real and
401s on a missing/forged token before that dependency resolves.

* [chore] complexity: split 5 C-rank blocks to <=B for the xenon gate

No behavior change; each C-rank function factored into a helper so the
complexity gate (xenon --max-absolute B) holds.

- lifecycle.can_invoke_action: extract the team-match check into
  _check_team_match.
- a2a.cancel_task: extract _status_value_of + _apply_cancel_note.
- task._apply_pre_block_restore: extract _restore_block_ownership (status/
  owner restore + snapshot clear) and _emit_admin_override_audit (#2176).
- release_proposal.approve: extract _finalize_release_lock (heartbeat/
  execute cancel + mutex release) out of the finally.
- kanban.get_main_pm_board_flat: dict-dispatch the column routing instead
  of a 7-branch if/elif ladder (status wins over team; in-flight + no cell
  team falls through to Coordination, #196).

* [chore] lifecycle artifacts: regenerate to match the spec (foundation-check)

The rendered artifacts (docs/rag/lifecycle, panel/lib/lifecycle.json, the
_generated role-prompt fragments) had drifted from the spec — the prior
sweep commits (cancel-CEO gate, claim_pr_review preconditions, pr_reviewer
unclaim, complete merge-first ordering) changed spec data without
regenerating, and the foundation-check render+diff stage never ran because
mypy failed earlier in the gate. make foundation-check now passes.

* [fix] chat: wire live message delivery end-to-end (MESSAGE_SENT)

send_message persisted messages but never broadcast them, there was no
MESSAGE_SENT event type or bridge forwarder, and the panel session view
had no websocket subscription — the live chat path was dead end-to-end.

- add EventType.MESSAGE_SENT and publish it best-effort on every persisted
  send (a bus outage logs, never rolls back the durable row)
- bridge _handle_message_event forwards to /ws/sessions/{id} and
  /ws/channels/{id}; subscribe it in register_websocket_bridge_handlers
- panel useSessionStream subscribes the session view; the page invalidates
  the transcript + session-detail queries on each message.new so the held
  (staleTime Infinity) views refresh live without the manual Refresh

* [fix] chat: return session task_links in one read; drop panel N+1

GET /sessions/{id} ran a bare select and session_to_response omitted
task_links, so it always returned them empty — the panel worked around it
with a triple-fetch (get session, get-tasks-for-session which re-fetched
the same endpoint, then a task GET per link), and the links never showed.

- add get_session_with_links(_or_raise) that eager-loads task_links -> task
- add session_to_response_with_links; GET /sessions/{id} uses both
- panel useSession now relies on the single populated response; remove the
  dead getTasksForSession + per-task fetch and the unused tasksApi import

* [fix] chat: validate reply_to against the effective session; guard closed-session composer

Posting to a closed session transparently redirects the message to the
group's active session (intended for agents holding stale refs), but
reply_to was validated against the requested session, not the one the
message lands in — letting a cross-session reply slip through — and the
panel silently posted there too, so the message vanished from the view.

- validate reply_to against session.id (the effective, possibly-redirected
  session), not req.session_id
- panel: render a "session is closed" notice instead of the composer for a
  non-active session; if a send still lands elsewhere (stale status), toast
  that it went to the active session rather than letting it appear to vanish

* [fix] chat: close session/group/message read IDOR; fix doubled 404s

get_session and the messages-list took an agent id but never used it, and
get_group took none at all — any authenticated agent could read any private
channel's group, session, and message transcripts. Three NotFoundError sites
also passed a full sentence as resource_type, yielding "... not found not found".

- add require_group_read_access / require_session_read_access (channel
  member / silent observer / privileged, mirroring list_group_sessions_for_agent)
  and get_session_with_links_for_agent; enforce on GET /sessions/{id},
  GET /sessions/{id}/tasks, GET /messages, GET /groups/{id} (-> 403 on deny)
- fix the three doubled-404 sites to the NotFoundError(resource_type, resource_id) form

Also folds two gate fixes for the prior chat commits: cast session.id to UUID
for the reply_to validation, and ruff import/format touch-ups.

Note: POST /messages intentionally still skips the channel write-ACL on the
HTTP (human-CEO/panel) path — the CEO is not in writers for 8/11 channels, so
enforcing it there would block the panel; the gateway/agent path enforces it.

* [fix] secretary: harden live chat — stuck spinner, mid-reply clobber, reload

The Secretary live chat had three live-behaviour bugs: a dropped SSE
connection left a permanent "thinking…" spinner (openStream set no
transport-error handler, so the no-data error Event was swallowed by the
JSON-parse guard and streaming never reset); sending mid-reply wiped the
accumulation buffer and pushed a user message without guarding the in-flight
turn, abandoning/duplicating the reply; and the chat lived only in React
state, so a reload wiped it.

- route the dual-purpose `error` listener: server-sent JSON → handleEvent,
  transport error (no data) → reset streaming, surface a notice, close stream
- guard send while streaming (streamingRef); disable the composer Send/Enter
  while a reply is in flight
- persist sessionId + messages to localStorage (TTL'd) and, on mount, restore
  + re-attach the stream once the backend confirms the session is still alive
  (mirrors the intake/prompter durability)

* [chore] groups: extract group-read helper to keep module rank A

The get_group IDOR access-check added try/except branches that tipped the
module to xenon rank B. Extract the service-error→HTTP mapping into a small
helper so get_group stays lean and the module is rank A again (behaviour
unchanged; covered by the groups route tests).

* [fix] chat: correct panel session-task mutation endpoints

linkTask/unlinkTask posted to /add-task and /remove-task (with a body), but
the backend exposes POST /sessions/{id}/tasks and DELETE
/sessions/{id}/tasks/{task_id} (path param) — so every call 404'd. updateTaskLink
targeted /update-task, a route that does not exist at all. Point linkTask and
unlinkTask at the real routes and drop the phantom updateTaskLink. All three were
unused, so no behaviour changes today — this removes a latent 404 trap.

* [docs] chat: document live message delivery (MESSAGE_SENT / message.new)

Document the live transcript-update path the chat-subsystem fixes wired:
- docs/api/websockets.md: add the message.new event-types row (carried on
  /ws/sessions + /ws/channels from EventType.MESSAGE_SENT) and note the
  forwarder sets type:"message.new"
- docs/panel/communications-and-journals.md: the session transcript updates
  live; a closed session is read-only (composer disabled)
- CLAUDE.md: name message.new on the per-resource streams and make
  MESSAGE_SENT the worked example of the add-a-live-event recipe

The internal roboco_map slices (gitignored) were updated in place to match.

* [docs] reconcile published docs with code since v0.13.0

Drift caught by the doc-reconciliation pass (all verified against HEAD):
- CLAUDE.md + rag: pr_reviewer gained the unclaim verb (16b71be8)
- rag permissions/task-states/task-tools: awaiting_ceo_approval -> cancelled is
  CEO-only, not PM+CEO (16b71be8 cancel-ceo-gate; lifecycle.py:373-382)
- deploy/env-reference: ROBOCO_APP_VERSION default 0.9.0 -> 0.14.0 (config.py:31);
  add ROBOCO_RELEASE_CI_WORKFLOW row (2759edf7, config.py:454)
- deploy/data-and-migrations: 44->54 revisions, head 054_a2a_message_skill
- optional/autonomous-maintenance: CI-watch dedupe is per (repo, workflow) (d34bc1a7)

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-01 01:11:34 +02:00
536bbb64f3 Chore/all/logical gaps sweep (#286)
* release-manager: fencing-token mutex + executor/readiness hardening

Closes the release-mutex TTL race (#17, HIGH) and the remaining
release-manager gaps (#88, #89, #201, #202):

- #17: the release mutex is now acquired with a uuid4 fencing token and
  released via Lua compare-and-del; a background asyncio heartbeat
  compare-and-expires the TTL ~every 60s while the execute owns the lock,
  so a live execute no longer expires and a crashed one auto-releases
  <=3000s. A second approve after TTL expiry cannot usurp and rm -rf the
  in-flight clone — the fenced first-finally keeps its lock.
- #89: a Redis outage during acquire stays fail-closed (the execute never
  runs) but now returns a distinct redis_unavailable result + log so the
  CEO sees the cause instead of a false already_in_progress.
- #88: commit_and_push RuntimeError is wrapped into a structured
  ReleaseResult(commit_failed) instead of a 500.
- #201: first-release fallback still emits untracked version-ref files as
  gaps (no longer silenced by the first-release branch).
- #202: _await_proc awaits proc.wait() after kill() so a timeout cannot
  leak a zombie.

TDD: tests/unit/services/test_release_proposal_concurrency.py extends
_FakeRedis with eval/get/expire and pins the fencing/heartbeat/usurper
invariants + the redis_unavailable result.

* PM/code-task creation guard + main_pm coverage + issue carve-out

Closes the creation-time role x task_type gap (the user's explicit example)
and the main_pm delegate hole:

- New pure helper `pm_cannot_own_code(role, task_type, is_issue_resolution)`
  in foundation/policy/batch.py — single source of truth. Both PM roles
  (cell_pm + main_pm) coordinate; a `code` task assigned/claimed by a PM is
  a structural mismatch, EXCEPT a PM taking a code task in needs_revision to
  resolve review/QA issues directly (the carve-out).
- Creation-time guard: TaskService.create calls the helper (closes the
  create-with-cell-PM-assignee hole the team-based check misses).
- Delegate path + spec claim gate consult the same helper.
  `_validate_assignee_task_type` / `_task_type_hint_for` now key on the
  Role (CELL_PM OR MAIN_PM), not the cell-PM slug set — closes the
  delegate-to-main-pm-as-code hole.
- identity.role_for_uuid_or_none is None-tolerant (treats None as "not a
  PM" and proceeds) so a malformed/missing assignee cannot crash the guard.
- prompter.create_task_from_draft reuses the guard at draft-create.

TDD: test_batch.py (helper matrix + carve-out), test_main_pm_code_guard.py
(main_pm coverage), test_delegate_assignee_task_type.py (delegate parity),
test_lifecycle_spec.py (claim gate: rejects PM claiming code from pending,
allows from needs_revision + PM claiming planning + dev claiming code).

* task-service: completion hooks + escalation/cancel/audit hardening

Closes the task-service cluster (#21/#98, #99, #100, #101, #103, #216; #102
verified already-covered, #217 verified already-guarded):

- #21/#98: ceo_approve now closes the work session + triggers completion
  hooks before worktree removal (no-op when work_session_id is None), so a
  CEO-approved task lands the same close-path as PM-completed.
- #99: apply_escalation routes through the transition validator with an
  enumerated escalation exemption (_ESCALATABLE_TO_BLOCKED) instead of an
  arbitrary source->BLOCKED write; BACKLOG is refused.
- #100: branchless ceo_reject awaiting_ceo_approval->pending gets a real
  spec edge (ceo_reject_to_pool ActionSpec + _STATUS_TRANSITIONS entry) so
  future admin-override tightening can't wedge the path.
- #101: revision_count bump is documented as the single chokepoint, with
  the pre-block RESTORE path undoing it when restoring a snapshotted
  needs_revision (same cycle resuming, not a new rejection).
- #103: cancel cascade surfaces non-terminal orphans instead of swallowing
  the role violation.
- #216: _remove_task_worktree_on_terminal escalates recurring FS/permission
  failure (audit/notify after N) instead of silent-failing forever.
- #102: pinned in test_verb_runner_midverb_invalid_state.py (committed with
  the choreographer cluster) — verb-runner savepoints already surface a
  concurrent mid-verb state change as INVALID_STATE.
- #217: submit_for_qa claimed_by guard verified intact.

TDD: test_task.py, test_worktree_cleanup_on_complete.py,
test_escalation_board_guard.py (#99), test_task_service_* integration,
test_lifecycle_spec.py.

* choreographer: gate-claim guards + pr-gate hardening + fail-open logging

Closes the choreographer cluster (#5/#222, #29, #30, #82, #188, #189,
#192; #157/#187 verified already-fixed/pinned; #102 pin lives here):

- #5/#222: the unchanged-PR guard's fail-open head_sha lookup now logs
  (warning) on a slug-resolver/git-helper error so a regression cannot
  silently turn the pr_fail re-submit loop-stopper into a no-op. Stays
  fail-open (never wedges the PM).
- #29: pinned (REFUTED-with-pin) — _lane_claim_guard already returns the
  error envelope without releasing the claim on a transient lookup error.
- #30: pinned (REFUTED-with-negative-pin) — a non-batch branchless main_pm
  root cannot bypass the complete spec gate (is_batch_umbrella requires
  batch_id set).
- #82: _post_gate_review_to_pr wraps the slug-resolution call in try/except
  (mirrors _capture_pr_head_sha) so a malformed cell_map AttributeError no
  longer 500s the reviewer after a committed gate transition.
- #188: _is_hand_formatted_verdict anchors the header regex to line-start,
  so a quoted (> ## Summary) or inline (mid-prose) header mention no longer
  false-refuses a hand-formatted verdict.
- #189: pr_fail re-captures the PR head SHA after the transition commits and
  re-stamps the verdict note only when it advanced (closes the stale-SHA
  false-allow loop-hole); no-advance stays a single note write.
- #192: claim_gate_review skips the dev claim guards (already_active/paused/
  lane) via a new skip_dev_guards param — a pr_reviewer inspecting an
  assembled PR does not start work, so the single-active-task / code-lane
  invariants do not apply; the dependency guard is kept, and QA's
  claim_review parity is preserved.
- #157/#187: verified in tree — pr_review-only handoff is intentionally
  prior-work-worth-resuming; self_review_block wiring (reviewer != dev)
  holds on assembled tasks with 4 existing pin tests.

TDD: test_choreographer_*, test_pr_gate_posts_review (#82),
test_pr_review_hand_format_guard (#188), test_submit_root_unchanged_pr_guard
(#189), test_claim_gate_review_guards (#192), test_verb_runner_midverb
_invalid_state (#102 pin).

* playbook curate: guard the gating commit against a poisoned session (#55)

The explicit `session.commit()` that gates the RAG index (commit-before-index
so an uncommitted playbook cannot land in the corpus) raised PendingRollbackError
when a prior mid-verb failure had rolled the caller's session back — 500ing the
whole curation verb instead of returning a clean envelope, and (worse) risking a
fall-through to index an uncommitted playbook. Wrap the commit: on
PendingRollbackError, log + return invalid_state with a re-fetch/retry remediate
and skip the index. The happy path still commits exactly once then indexes.

TDD: test_playbook_verbs.py — poisoned-session returns a clean invalid_state and
does NOT index; clean-session still commits once + indexes (pins no fail-closed
inversion / no double-commit).

* [chore] gateway: atomic activate merge — preserve probe_failures across re-park (#156)

activate() was a blind SET that reset probe_failures to 0, so a probe-failure
increment that just landed (or was in flight) could be wiped by a concurrent
re-park — resetting the give-up / CEO-notify count mid-episode. Route activate
through a server-side Lua merge (roboco:activate_rate_limit) that refreshes the
episode metadata (kind / activated_at / retry_after / affected_agents) while
carrying over the previous probe_failures count. Indivisible w.r.t. the
increment/reset scripts (Redis single-threads an EVAL).

#56 (notify to prompter/secretary refused) verified SAFE — the pin tests
(test_notify_rejects_prompter_recipient / _secretary_recipient /
_allows_ceo_recipient) already cover the only human notify target invariant;
no legitimate send is dropped, no code change.

* [chore] foundation/policy: spec gates + QA retry-key pin (Cluster F)

#50 sync_branch composes=() so the spec gate accepted a terminal/paused/
blocked task and the handler rebased a dead/parked branch — add a
PRECONDITION_SYNC_BRANCH_STATE (claimed/in_progress/verifying/needs_revision
only), rejection_kind=invalid_state. TDD: 28 spec tests.

#148 submit_root's prose asserts 'a Main-PM root is planning-typed, never
code' but only the creation path (main_pm_cannot_own_code) backed it — add
PRECONDITION_ROOT_NOT_CODE on the submit_root IntentSpec (defense in depth),
scoped to submit_root only so the shared submit_for_review action keeps
cell_pm+code submit_up parity. Graceful on Mock/None task_type so the
choreographer Mock-task tests don't crash. TDD: 2 spec tests.

#150 VERB_RETRY_LIMITS is keyed by the MCP-exposed names (pass/fail), not
the IntentSpec-internal pass_review/fail_review — already correct; add a
pin test so a one-sided rename can't silently drop the QA-handoff cap.

#142 main_pm_cannot_own_code/pm_cannot_own_code already normalize casing
(.lower()) — no-op, pin test test_main_pm_cannot_own_code_is_case_insensitive
already in tree.

* [chore] worksession-git: 405 merge-method fallback + non-destructive close (Cluster W)

#108 _merge_with_retry hardcoded 'squash' and raised MergeConflictError on a
405 with no method fallback — wedging the PM on an open, mergeable PR whose
repo merely had the squash button off. Add a 405 fallback to a permitted
method (via _first_allowed_merge_method, exclude='squash'), mirroring the CEO
merge_pull_request path. A 405 with no permitted fallback (or a second 405)
still falls through to the already-merged disambiguation / MergeConflictError.
TDD: 2 new tests (fallback-success, no-permitted-method-raises).

#109 close_pull_request defaulted delete_branch=True, so the choreographer
supersede path deleted a superseded PR's branch while the orchestrator
supersede path explicitly preserved it — the two disagreed, and the
destructive default ran on the 'close the dead PR' path where the branch may
still be referenced / useful for audit. Flip the default to False (opt-in
deletion) and make the choreographer caller explicit (parity with the
orchestrator). TDD: 1 new test (default preserves branch); existing
deletion-when-requested test now passes delete_branch=True explicitly.

Dispositions verified against current code (no silent drops):
- #27 REFUTED/FIXED-UNDEPLOYED: work_session.merge_pr resolves by session_id
  (no global pr_number lookup); the real cross-repo collision fix
  (project_id scoping on pr_merge/close_pull_request/rebase_pr_for_task/
  pr_target) is already in tree + tested (test_pr_merge_scopes_task_lookup_
  by_project_id, test_close_pull_request_scopes_task_lookup_by_project_id,
  test_git_pr_target_scoping). Verify-only.
- #106 REFUTED: a guard exists (rev-list --count {base_ref}..{branch} == 0)
  before reset --hard + base_ref falls back to default_branch; tests lock
  the safety (test_create_branch_never_repoints_branch_with_real_work,
  test_create_branch_does_not_reset_or_checkout_shared_clone).
- #218 BY-DESIGN: the merge_pr idempotent guard intentionally preserves the
  audit trail (docstring + test_merge_pr_idempotent_on_already_completed_
  preserves_audit_trail); a COMPLETED session always carries attribution
  (COMPLETED only via merge_pr), so the NULL-COMPLETED case is unreachable.
- #104 BY-DESIGN: agents never merge to the repo default branch in RoboCo's
  model (root→master is CEO-only); the guard is a correct CEO-only rail,
  locked by test_pr_merge_into_default_branch_is_ceo_only.

* [chore] llm: surface disabled-provider downgrade + scrub probe log (#20/#3/#211)

#20/#3 resolve_for_agent silently fell through to the legacy Anthropic path
when a configured provider was disabled — indistinguishable from 'no
assignment', so the operator got no signal that spawns bypassed the
provider. Surface the bypass with a warning (graceful degradation stays the
default — a stalled spawn is worse than a routing miss) and add an opt-in
ROBOCO_ROUTING_STRICT (default-off) that fail-closes instead. Wired into the
panel Feature Flags card. TDD: 3 unit tests (warn-on-disabled, strict-raises,
no-assignment-stays-silent).

#211 probe_ollama_tags logged str(exc) raw on the generic-exception branch —
structured log could carry connection internals / stack traces. Log the
exception class name only. TDD: existing generic-branch test strengthened to
assert the log kwargs don't leak the raw text.

* [chore] support/stream/optimal/playbook/comms hardening (Cluster S)

Logical-gaps sweep, Cluster S (TDD, red→green per item):

#64 notification_delivery.acknowledge published the NOTIFICATION_ACKED bus
event directly (bypassing the outbox) — a rollback left a phantom ACK. Route
it through defer_bus_publish (after_commit), mirroring deliver.

#76 playbook.archive()/reject() stamped the archiver into approved_by/
approved_at, overwriting approval provenance (and fabricating approval for a
rejected draft). Add archived_by/archived_at (migration 053 + table + model)
and write those on archive/reject, leaving approval attribution intact.

#181 vector_store.replace_chunks wiped existing index rows even when every
chunk lacked an embedding (embedder failure). Skip the wipe when chunks is
non-empty but records is empty — preserve good rows for nothing.

#182/#183 optimal.record_learning recomputed a learn-{md5(full_content)}
tracking source that never matched the URI the plugin embedded chunks under
(roboco://learnings/{doc_id}, doc_id=lrn-{hash100}). Use the plugin's
returned doc_id so de-index/lookup-by-source finds the chunk rows.

#96/#97 transcription periodic flush only peeked ready buffers (unbounded
map growth) and ran sync callbacks on the event loop (a slow callback
blocked the flush task). Flush (remove) each ready buffer after notifying,
and offload each callback to a thread.

#212 _TEAM_SCOPED_ROLES was duplicated across communications/agents_config/
seeds. Single-source it in foundation.policy.communications; consumers
reference that object (identity-tested).

#19 stream_bus._dispatch_event re-ran already-succeeded handlers on a
recover_pending replay (duplicate side effects). Add a per-(event.id,
handler) SET-NX idempotency guard: skip on a hit, clear the key on handler
failure so a replay re-runs it, fail-open when redis is unavailable.

Dispositions (no code change): #77 approve() index-write pair asserted
BY-DESIGN; #62/#63 notification DB-dedup verified pinned; #184/#185 REFUTED;
#214 REFUTED; #215 BY-DESIGN.

* [chore] db/migrations: graph-integrity guard + conftest unreachable-DB warning (Cluster D)

Logical-gaps sweep, Cluster D (TDD + real alembic upgrade head verification):

#16/#37 add tests/unit/test_migration_graph_integrity.py — a static guard that
the alembic migration graph has exactly one head, every down_revision resolves,
every revision is reachable from a root, and no revision id is duplicated. The
suite builds its DB via Base.metadata.create_all (not alembic upgrade head), so
a forked head / dangling down_revision / duplicate id would otherwise ship
silently and break a real deploy mid-stream.

Caught a real bug in the process: migration 053's revision id
"053_playbook_archived_attribution" (33 chars) exceeded alembic's
alembic_version.version_num VARCHAR(32) — a fresh `alembic upgrade head` raised
"value too long for type character varying(32)" at the 053 stamp. Renamed to
"053_playbook_archived_attr" (26 chars). Verified end-to-end on a scratch PG:
upgrade head stamps 053, downgrade -1 returns to 052. (The pre-existing
test_every_migration_revision_id_fits_the_alembic_version_column guard is now
green too; it had been red on the 33-char id.)

#90 conftest silently pytest.skip'd every DB test when Postgres was unreachable
— a non-Docker box reported a green run of all-skips. Extract the warning into
_warn_if_pg_unavailable and fire it at import so the operator sees the DB is
down (the per-test skip path is unchanged). Test: warns when unavailable, silent
when reachable (verified under -W error::UserWarning).

Dispositions (verified against real code + a fresh alembic upgrade head, no code
change): #6 REFUTED — sa.Enum(create_type=False) at 001:119/304 does NOT break a
fresh upgrade head (001→052 applied cleanly on a scratch DB); #8 REFUTED — the
upgrade passed 030/031 (RAG chunk tables) without pgvector installed; pgvector is
a runtime concern handled by roboco/db/base.py, not a migration prerequisite;
#40 REFUTED — the `|| echo` mask was already removed and partial-schema drift
reports exit 1 (only by-design unreachable/unmigrated skips remain); #204 REFUTED
— the property walk seed IS pinned (random.Random(20260504), line 97); #205/#206
REFUTED — the smoke-trace fixture IS wired via
test_lifecycle_smoke_replay.py (8 passed); no shell smoke scripts exist in the
tree to wire; #137 BY-DESIGN — pyproject version 0.14.0 is an operational note,
no code gate.

* [chore] panel: admin-override force flag + kanban subtask_count + ws cleanup + ui-store dedupe (Cluster P)

#13: kanban admin-override into a hatch state (completed / awaiting_qa /
awaiting_pm_review) now requires an explicit force=true from the panel and
emits a dedicated task.admin_override audit row server-side; non-hatch
overrides need no force. Backend gate in tasks route + admin_set_status;
panel kanban-board sends force for hatch targets; TaskUpdate carries force.

#198: kanban service threads the real subtask_count (one grouped query) into
dev + priority-swimlane + main-pm-flat boards instead of a hardcoded 0.

#79: useWebSocket cleanup clears messages/lastMessage/state on unmount or
endpoint change so a dep-change (navigating to another stream) can't leak the
prior subscription's stale snapshot as live.

#186: disambiguate the duplicate ui-store modules -- the session/scroll store
in lib/stores renamed to useScrollRestorationStore / scroll-restoration-store
(barrel + 2 consumers updated); the sidebar/theme useUIStore in @/store is now
the sole useUIStore.

#12: verified already in-tree (release-proposal-card surfaces non-404 errors
with retry; getProposal maps only 404->null). #80 by-design (handleTransportError
already resets isSending on a no-payload SSE drop). #81 docs (streamUrl docstring
records that live-intake SSE auth is session-id-based bearer-style).

Backend: ruff+mypy clean, 278 tests green. Panel: lint+typecheck clean, 159 tests.

* orchestrator: park/reaper/readopt/a2a hardening + self-heal/ci-watch dedupe (Cluster O)

Closes the orchestrator-side logical gaps from the sweep:

- #75 a2a human-only drop surfaced: _dispatch_a2a_work logs the skip
  ("a2a request targets a human-only role; left as a notification for the
  human (not spawned)") instead of silently dropping the target — the
  CEO/secretary/prompter still see the notification; only the spawn is
  suppressed. (orchestrator.py)
- #72 readopt liveness: _readopt_running_agents requires a non-stale live
  claim (via _agent_holds_live_claim) and skips a zombie container so a
  reaped-but-restart-readopted agent isn't double-counted as active.
- #74 shutdown drain: stop() calls _flush_respawn_tracker so the durable
  respawn counter write-throughs aren't lost on a clean stop.
- #71 resolve_wait active-guard + deferred liveness: a rate_limit_lifted
  WaitingRecord is only confirmed-live after a _confirm_resume_liveness
  probe (deferred deletion _resume_confirm_delay=30.0), and an
  already-active agent short-circuits the repark. Scoped to
  rate_limit_lifted records (the only ones at risk of a false lift).
- #73 stuck-Claude kill: _maybe_kill_stuck_claude + _claude_stuck_kill_ttl
  (config.claude_stuck_kill_seconds) — a live container whose heartbeat is
  stale past the grace AND whose gateway probe is broken is killed+evicted,
  not protected forever by the reaper's live-skip.
- #230 verified FIXED-UNDEPLOYED: _gateway_broken_past_grace already
  requires N consecutive false-broken probes (not one flaky streak); no
  change, test added to pin the N-consecutive invariant.
- #43 self-heal per-observation dedupe: a fingerprint collapses repeat
  CEO notifications for the same CI regression.
- #44 ci_watch dedupe by (git_url, workflow): a monorepo's multiple
  workflows each get their own fix task (was collapsed by git_url alone).
- #49 identity.role_for_slug_or_none None-hardening: a stale/malformed
  slug resolves to None and the human-only skip falls through to the safe
  "not spawnable" path instead of crashing.
- #193 strategy engine: notify the CEO on a persistent assess failure
  instead of failing silently in the background loop.

TDD: test_no_spawn_human_roles (a2a skip surfaced), test_orchestrator_
shutdown_drain (#74), test_provider_overload_break (#71), test_readopt_
running_agents (#72), test_resolve_wait_repark (#71), test_stale_claim_
reaper (#73/#230), test_strategy_engine_loop (#193, new),
test_self_heal_engine (#43), test_ci_watch_engine (#44), test_identity
(#49). All red->green.

* chore: make-quality green — xenon complexity refactors + mypy test fixes + lifecycle regen

No behavior changes. Brings the tree to a fully green `make quality` (the
base branch never passed the xenon B-rank gate on several blocks; the
lifecycle artifacts had drifted from the committed ceo_reject_to_pool edge).

Xenon B-rank refactors (extract a helper; preserve semantics exactly):
- api/routes/tasks.py: _apply_forced_status_override + _StatusOverride
  dataclass bundle (update_task override block).
- services/task.py: _enforce_no_pm_code_on_create (create guards) +
  _escalation_diverts_to_pool (collapses the two board/advisory +
  main_pm+code divert branches into one predicate).
- services/prompter.py: _coerce_pm_code_to_planning (create_task_from_draft).
- services/notification.py: _duplicate_unacked_exists (_create_notification
  purpose-based dedup query + ACK_REQUIRED_BY_TYPE gate).
- services/sequencing.py: _same_assignee_lane_edges (the undeclared-surface
  same-assignee lane fallback at the tail of dev_task_collision_edges).
- gateway/choreographer/_impl.py: _pm_task_type_error static helper
  (_validate_assignee_task_type compound PM guard).
- gateway/choreographer/pr_gate.py: _gate_review_event_verdict +
  _gate_review_body static helpers (_post_gate_review_to_pr).

mypy test fixes (no type:ignore — banned; use typing.cast with quoted
strings per TC006):
- test_task_update_completeness: TaskUpdate(acceptance_criteria=None).
- test_bus: cast("Redis", _FakeRedis()); Redis import under TYPE_CHECKING.
- test_pr_merge_concurrency: capture AsyncMocks into locals before asserting.
- test_notification_delivery_phantom: cast("UUID", to_agents[0]).

Lifecycle artifact regen (owed from Cluster T #100 — the
awaiting_ceo_approval -> pending `ceo_reject_to_pool` edge was added to the
spec in 3d633084 without regenerating the derived artifacts the
foundation-check gate diffs against): docs/rag/lifecycle/intent-verbs.md,
docs/rag/lifecycle/status-transitions.md, panel/lib/lifecycle.json.

services/kanban.py: ruff format only (collapses the _load_subtask_counts
signature that drifted unformatted from Cluster P).

* [chore] logical-gaps sweep — Cluster I (intake/product/pitch)

#57/#58 prompter: preserve a top-level product_id with a 1-cell map
(prompter.py create_task_from_draft — top-level target wins over a
redundant 1-cell map instead of dropping product_id); reject — not
silently skip — a malformed project_id in the_work cell entries
(prompter.py _draft_cell_map raises ValidationError).

#59/#159 prompter: create_task_from_draft now operates on a copy
(_copy_draft) so _validate_and_coerce_draft / _clean_list never mutate
the caller's draft dict.

#160 prompter: _resolve_owning_team consults product/board routing
before forcing MAIN_PM on a multi-cell map (product root stays Board,
product+assignee-is-board stays Board).

#83/#84 github_provisioning: create_repo is idempotent by GitHub name
— a 422 "name already exists" (orphaned repo from a rolled-back prior
approval) is fetched and reused instead of erroring; pitch re-approval
now reuses the orphaned repo end-to-end.

#196 kanban: flat main-PM board has a "coordination" column for
non-cell teams (MAIN_PM/Board) instead of dropping their cards.

#197 project update: an explicit null in the PATCH body now clears the
stored field, distinct from an absent field (leave unchanged).
ProjectService.update drops exclude_none so explicit-None applies; the
PATCH route uses ProjectUpdate.model_validate(data.model_dump(
exclude_unset=True)) to preserve the request's unset-tracking (the old
field-by-field construction marked every field set and defeated the
distinction — nulling NOT-NULL git_url).

TDD: prompter 47, github_provisioning+pitch 16, kanban+project 67,
project routes 37 — all green; ruff + mypy clean.

* [chore] logical-gaps sweep — Cluster M (mcp-servers)

#60 flow_server/do_server: the circuit-breaker substitution no longer
erases the fixable rejection — the original envelope (kind/message/
remediate) is nested as inner on a copy of the SDK's circuit_open
envelope (the SDK dict is not mutated in place). The agent still sees
WHY the verb failed, not just that the breaker tripped.

#61 flow_server/do_server: a 404 carrying a *descriptive* detail
(not FastAPI's bare default {"detail":"Not Found"}) is now a
real resource not_found, surfaced as not_found so the agent
re-fetches state — instead of a misleading "server-side wiring gap"
invalid_state. The bare default and unparseable 404s still synthesize
the wiring-gap envelope; a 404 with a real Envelope (error field)
is still surfaced as-is.

#161 flow_server/do_server: dict error.code classification now uses
an exact-code map (authoritative for the codes the handlers emit) with
a substring fallback for unknown codes. Fixes the real regression:
AUTHENTICATION_REQUIRED carries no AUTHORIZED/DENIED/PERMISSION
substring, so the old substring-only rule dropped it to invalid_state
instead of not_authorized — an auth storm attributed as a state storm.
The fallback also adds AUTH so future AUTH-prefixed codes classify.

#162 flow_server/do_server: _register_tools gains a
ROBOCO_ALLOW_FULL_TOOLSET env override (default-off) so a missing
manifest falls back to the full tool set instead of raising — a
dev/test escape hatch. Production fail-loud behaviour is unchanged.

#163 intake_server: propose_batch accepts name as well as
title (intake drafts in the wild have used both), normalizing a
name-only draft onto a copy as title (caller's dict never mutated),
and reports the dropped count + reason in the return instead of
silently vanishing malformed drafts. The empty-batch hint now names
name as an alternative.

TDD: 123 mcp_servers tests green (14 new + 2 updated); ruff + mypy clean.

* [chore] Cluster N — conventions/docs logical-gaps sweep

#33: _create_new_doc/_update_existing_doc now resolve via
_resolve_contained_path (the RAG-returned update path was not containment-
checked — an escaping source could write/overwrite outside the docs dir).
#34: _commit_doc_to_repo returns committed/skipped/failed instead of
swallowing all exceptions; surfaced on DocRef.commit_status, the write
response, and the docs MCP guidance so a failed repo commit is fail-loud.
#35: write_doc only updates the similar doc when its filename matches — a
different filename creates a new file instead of collapsing onto the
similar doc's path (the dedup-overwrite defect codified by the old tests).
#129: a custom rule scoped to a language the validator never reports (a
typo) is surfaced as a warn finding on .roboco/conventions.yml via the
runner's once-per-run validation; #32 (tsx->typescript dialect) stays
BY-DESIGN.
#130: _cache_put only swallows a UNIQUE violation (23505) as a concurrent
duplicate; a non-unique IntegrityError (FK/NOT NULL/check) is log-errored
and re-raised instead of being silently misattributed.
#132: health re-reads the live file status (a cached degraded row hid an
in-place repair at a stale head key); get_map skips cached degraded rows
and stops caching degraded so a repaired file re-derives. #134 BY-DESIGN.
#133: _DB_METHODS gains stream/stream_scalars (SQLAlchemy 2.0 streaming
constructs are data access too — a route calling them is not thin).
#199: regenerate_verb_tables._annot_str strips Annotated[...] metadata
(BeforeValidator) before rendering; regenerated verbs.md + per-role
prompts so the BeforeValidator(func=...) repr (with a memory address) no
longer leaks into agent-facing prompt text.

TDD: 206 conventions/docs tests green (incl. 5 new files / appended
cases); ruff + mypy clean.

* [chore] Cluster 16 — cross-cutting hygiene logical-gaps sweep

Disposition + fix the 10 cross-cutting-hygiene gaps, TDD. make quality green
(ruff, mypy 944 files, pytest, xenon, vulture, foundation-check, enum-parity).

FIX:
- #24 /ws/system now gated by _require_panel_token (matches every sibling
  /ws/* stream); rejects a missing token in strict mode and a forged token
  even in dev. (roboco/api/websocket.py)
- #25 two drifted _require_ceo implementations (orchestrator router vs release
  handler) unified on a single require_ceo_role helper in deps — same 403,
  same role set, accepts Role/AgentRole/"ceo". (roboco/api/deps.py,
  routes/orchestrator.py, routes/release.py)
- #11 a spawn session for a delivery role (developer/qa/documenter) with no
  task_id now logs an unattributed-usage warning via is_unattributed_delivery_spawn.
  (roboco/runtime/orchestrator.py)
- #65 pricing returns a structured CostResult(cost_usd, unpriced, is_anthropic)
  so an unpriced Anthropic model (real spend we'd undercount) is flagged
  instead of silently $0; calculate_cost stays a thin float wrapper.
  (roboco/billing/pricing.py, billing/__init__.py)
- #67 blocker-metrics "blocked since" reads the task.blocked audit transition
  (indexed on target_id/event_type/timestamp), not updated_at — which
  over-counted when a blocked task was touched for a non-blocking reason.
  Falls back to updated_at/created_at only with no audit row.
  (roboco/services/metrics.py)
- #94 grok refresh_if_stale uses double-checked locking (_refresh_lock +
  _recheck_or_refresh) so two concurrent callers don't both POST the
  single-use refresh grant and burn the credential. (grok_auth.py)

DOCS (fix the doc, behavior already correct/pinned by tests):
- #66 get_summary docstring corrected — it sums raw agent_spawn_sessions rows
  (sub-day precise); daily_usage_rollups/get_today_summary can diverge for
  "today" until the sweeper catches up. (roboco/services/usage.py)
- #68 DashboardStorage is a documented in-memory stub; added a test pinning
  that auditor flags are lost on storage reset (persisting = a migration +
  service refactor, out of scope as a half-implementation).
  (tests/integration/test_dashboard_service.py)

BY-DESIGN (no code change, with file:line evidence):
- #28 dashboard reads are open to the authenticated operator (dashboard.py:36
  documents this); mutating auditor routes already gate via
  _require_auditor_or_ceo. Role-gating reads would break the panel (no
  X-Agent-ID on dashboard reads) and CEO-token-gating the router would block
  the Auditor (auditor token != CEO token). nginx is the prod boundary.

REFUTED (narrowing would reintroduce a documented hang):
- #93 the ~/.grok directory mount (vs a single auth.json file) is load-bearing
  — a single-file bind mount pins the inode so the atomic tmp.replace refresh
  doesn't propagate to running containers (they hang at grok's login prompt).
  Already documented in grok.py:161 and locked by
  test_intake_grok_mounts_subscription_auth_when_present.

Incidental gate-greening (mypy errors a stale .mypy_cache had hidden in
earlier-cluster test files; xenon refactors for the new B-threshold):
- tests/unit/test_regenerate_verb_tables.py: type the dynamic-module loader.
- tests/unit/services/test_prompter.py: annotate the draft dict as dict[str,Any].
- tests/unit/services/test_conventions_cache_put.py: _FakeOrig is a real Exception
  (IntegrityError's orig arg requires BaseException).
- metrics._blocked_since_map extracted from get_blocker_metrics (complexity).
- intake_server._normalize_batch_drafts extracted from propose_batch (complexity).

* Docs update

* [bug] spawn: self-heal vanished clone + branch ref before worktree ensure (be-dev-1 fatal loop)

A vanished clone_root (disk loss / /data/workspaces wipe / manual cleanup)
fatal-looped the resume path: _ensure_worktree_before_spawn ran
`git -C <missing>` and released the claim, but the reaper-style release
preserves assigned_to + branch_name so the next dispatch is a RESUME
(create_branch never re-runs to re-clone) and the same missing clone failed
every ~30s.

- workspace.py: ensure_worktree_self_heal re-attaches a present worktree +
  symlinks the shared .venv; on a missing local branch ref it fetches from
  origin (create_branch pushes at claim time, so pushed work survives) and
  re-creates the ref, falling back to -b origin/HEAD only when the branch
  was never pushed. _fetch_branch_ref is the token-aware fetch helper.
- orchestrator.py: _ensure_worktree_before_spawn health-checks the clone
  and re-clones via ensure_workspace BEFORE the worktree self-heal. Fatal
  git-state (WorkspaceError) still releases the claim + aborts; transient
  failures abort without releasing (a fresh claim wouldn't help and
  re-cloning is destructive).

TDD: 21 new + 61 related worktree/git/cancel/cleanup tests green; ruff +
mypy clean.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-30 08:08:35 +02:00
5fe1e6df58 feat: in-path PR-review gate — per-cell + main reviewers (#229)
* feat(lifecycle): add the in-path PR-review gate status + reviewer verbs

Insert awaiting_pr_review between the assembled-PR submit and the PM merge,
giving the merge level the rejection capability it structurally lacks — today
only qa_fail and ceo_reject ever reach needs_revision, so a PM review is a
merge button with no teeth.

- New Status awaiting_pr_review + submit_for_review / pr_pass / pr_fail actions
  (pr_pass -> awaiting_pm_review, pr_fail -> needs_revision, mirroring the QA gate).
- Reviewer verbs claim_gate_review / pr_pass / pr_fail, and a main-PM submit_root
  verb (the root analogue of the cell PM's submit_up; opens the root->master PR).
- Extend the self-review-symmetry validator to the new sign-off actions.
- Mirror the value into the ORM TaskStatus enum + the A2A state map, and add the
  postgres taskstatus enum value (migration 040, forward-only like 037).
- Regenerate the per-role verb tables; add gate spec tests.

Spec surface only; the gateway methods + dispatch are wired in follow-ups, so the
verbs are advertised but dormant (flow_server tolerates unregistered verbs).

* feat(identity): add the three cell PR-review-gate reviewers

The in-path gate needs a reviewer per cell so each cell's assembled cell->root
PR is reviewed by a stack-specialized agent, while pr-reviewer-1 serves the
root->master gate (and keeps doing inbound external PRs).

- be/fe/ux-pr-reviewer: PR_REVIEWER role, team-scoped (so dispatch routes each
  cell's gate to its own reviewer); seeded identities + ROLE_TEAM_RULES + names.
  AI agent count 22 -> 25.
- They reuse the existing roboco-agent-pr-reviewer image (AGENT_IMAGES maps the
  three slugs to it, as be-dev-1/-2 share one image) — no new image.
- Tracing table: pr_pass/pr_fail require a learning entry (parity with
  post_pr_review), submit_root mirrors submit_up, claim_gate_review is waived
  (its tracing applies on pr_pass/pr_fail) — completes the verb surface added
  in the prior commit.
- Update the roster-pinning identity tests.

* feat(gateway): wire the in-path PR-review gate end to end

Make the assembled-PR review gate operational across the choreographer, the
TaskService transitions, and the v1 flow surface.

- TaskService: submit_for_review (in_progress→awaiting_pr_review), pr_gate_claim
  (no-transition reviewer claim), pr_pass (→awaiting_pm_review), pr_fail
  (→needs_revision); mirror qa_pass/qa_fail (clear claim, actor-mismatch warn,
  issues appended for the PM's revision). VerbRunner gains the matching atomic
  handlers + a create_root_pr side effect.
- Repoint submit_up to compose submit_for_review (cell→root PR enters the gate),
  and add a main-PM submit_root verb (opens the root→master PR, enters the gate).
- Split main_pm_complete: a code root must pass the gate first (requires
  awaiting_pm_review; rejects an in_progress code root toward submit_root and no
  longer reopens the PR), while a branchless coordination root still walks
  straight through, ungated.
- PRGateMixin (claim_gate_review / pr_pass / pr_fail) composed onto the
  Choreographer; flow_server forwarders + v1 routes (pr_reviewer + main_pm) +
  request schemas.
- Tests: gate spec + the updated submit_up / main_pm_complete expectations + new
  real-DB integration tests driving submit_for_review→pr_gate_claim→pr_pass and
  pr_fail through the real enforcement layer.

* feat(orchestrator): dispatch the in-path PR-review gate

Make the gate live in the dispatch loop.

- _dispatch_pr_gate_work: route awaiting_pr_review tasks to reviewers by level —
  a cell→root task to its cell reviewer (be/fe/ux-pr-reviewer), the root→master
  task to pr-reviewer-1. The reviewer self-claims via claim_gate_review (no
  pre-claim, mirroring the external-PR dispatcher); registered in
  _dispatch_all_work. _select_agent_for_cell learns the pr_reviewer role.
- _build_pr_gate_prompt: anchors the reviewer to the parent objective + full
  acceptance criteria + the FE<->BE contract, then pr_pass / pr_fail.
- _readiness_check_role_for_status: awaiting_pr_review -> pr_reviewer.
- Fail routing: pr_fail reassigns the failed assembled task to its PM
  (_revision_pm_for_task: cell PM for a cell team, Main PM for the root), and the
  revision dispatcher is generalized from coordination-roots-only to any
  PM-owned needs_revision task so the gate-failed task is re-coordinated instead
  of deadlocking.

* docs: document the in-path PR-review gate + the cell reviewers (22→25)

Reflect the shipped gate across the canonical + RAG docs.

- CLAUDE.md: agent count 22→25, the cell reviewers in the org chart, an
  awaiting_pr_review state + the gate transitions + a gate note in the lifecycle
  section, and submit_root / claim_gate_review / pr_pass / pr_fail in the verb
  surface table.
- docs/rag/architecture: org-structure (count, cell-reviewer roster, cells
  table), agent-uuids (be/fe/ux-pr-reviewer rows), agent-model (role + team
  rows).
- docs/rag/roles/pr-reviewer: the in-path gate section + the gate verbs.
- Wrap reviewer.id with UUID(str(...)) in the gate DB tests for mypy.

* docs: finish the gate doc sweep across README + RAG + generated artifacts

Catch the remaining surfaces beyond the canonical docs.

- README + how-to: agent count 22→25, the 6-agent cells (+ PR Reviewer), the
  main reviewer's root→master gate role.
- RAG: permissions + tool-permissions + task-tools list the gate verbs
  (claim_gate_review / pr_pass / pr_fail) for pr_reviewer; regenerate the
  lifecycle artifacts (intent-verbs, status-transitions, the per-role
  lifecycle-*.md prompts, panel lifecycle.json) from the spec via
  build_lifecycle_artifacts.py so they carry the new status + verbs.

* fix(migration): shorten the 040 revision id to fit alembic_version VARCHAR(32)

The revision id '040_taskstatus_awaiting_pr_review' is 33 chars; alembic's
alembic_version.version_num column is VARCHAR(32), so recording the migration on
a real 'alembic upgrade head' failed with 'value too long for type character
varying(32)' (surfaced on the NAS deploy). The test suite missed it: the test DB
is built via Base.metadata.create_all and the parity test only renders SQL
offline, so nothing actually applied the migration chain.

- Rename to '040_awaiting_pr_review' (22 chars).
- Add a guard test asserting every revision id fits the VARCHAR(32) column.
- Verified by applying the full chain 001->040 against real Postgres: it now
  reaches head and records '040_awaiting_pr_review' without truncation.

* fix(migration): land the actual 040 revision-id shortening + guard test

The prior commit captured only the file rename (git add aborted on the deleted
old path), leaving the long revision id and missing the guard test. This commit
carries the real content: revision id '040_awaiting_pr_review' (22 chars) and the
revision-id length guard. Re-verified against real Postgres — the full chain
reaches head and records the short id without truncation.

* fix(product): flush cell deletes before inserts when re-mapping projects

Editing a product's cell->project map (PATCH /api/products/{id}) 409'd with
'duplicate key value violates unique constraint uq_product_projects_product_team'
whenever a team already had a mapping. _replace_cells clears the old rows and
appends the new ones, but within a single flush SQLAlchemy orders INSERTs before
DELETEs for the same table, so the new (product_id, team) rows collided with the
not-yet-deleted old ones. Flush the deletes first.

Pre-existing bug (unrelated to the PR-review gate); surfaced on the NAS. New
real-Postgres regression test re-maps all three cells to different projects —
it fails with the unique violation without the fix and passes with it. The
existing update test only changed WHICH team was mapped, so it never collided.

* fix(gateway): let main_pm submit_root past the shared submit-up guard

submit_root reused the cell PM's _submit_up_ownership_guard, which
hardcoded agent.role != cell_pm and rejected the Main PM with
"submit_up is reserved for cell_pm". A branch-bearing code root could
then never close: submit_root bounced to complete, while complete
required awaiting_pm_review (reachable only via submit_root) and bounced
back — a circular rejection.

Both callers already run the spec gate (can_invoke_intent), which
enforces submit_up→cell_pm and submit_root→main_pm, so the guard's role
re-check was redundant for submit_up and wrong for submit_root. Broaden
it to accept either PM role as a defense-in-depth non-PM reject.

Adds the first choreographer-level submit_root test (the gap that let
this ship).

* fix(gateway): proactively steer both PMs to their bubble-up verb

The submit_root deadlock had a sibling steering gap: nothing told a PM
which verb opens the gate. The delegate next-hint said only 'i_am_idle
when done', and complete's in_progress rejection named submit_root for
the Main PM but left the Cell PM with a bare 'not ready for completion'
— no submit_up pointer, the same guess-the-verb trap.

- delegate hint now names the role-correct verb (root → submit_root,
  cell parent → submit_up) proactively, before any rejection.
- cell_pm_complete's in_progress rejection now steers to submit_up,
  mirroring the Main PM's submit_root gate hint.

Tests cover both the cell-PM steer and the role-aware delegate hint.

* docs: correct who-merges-which-PR across the gate docs + complete description

Audit of the gate docs found the merge actors mis-stated in several
places — the exact ambiguity that risks 'the reviewer/PM merges the root
PR' confusion:

- complete IntentSpec description said 'Main PM merges root PR' — false;
  main_pm_complete escalates and the CEO merges root→master. Corrected
  (propagated to intent-verbs.md, lifecycle.json, generated role prompts
  via build_lifecycle_artifacts.py).
- task-tools.md: submit_up target was awaiting_pm_review (should be
  awaiting_pr_review); Main PM flow had no submit_root — added it.
- README.md: lifecycle diagram now shows the awaiting_pr_review gate.
- cell-pm.md / main-pm.md: dropped the stale 'submit_up hands work to the
  Main PM who merges your cell branch' model — the cell PM merges its own
  gated cell→root PR; the Main PM owns the root + submit_root; the CEO
  merges master. Added submit_root to the main-pm manifest.
- git-commits.md, pr-creation.md, tool-permissions.md, git-tools.md:
  stopped attributing root→master PR opening to complete (it's submit_root).

No behavior change; verb wiring + state machine verified gap-free this
session (the pr_fail→needs_revision→PM respawn loop closes correctly).

* fix(orchestrator): stop closure respawn waiting the reaper window

A PM that finished its subtasks and idled left its parent 'paused' with a
fresh last_heartbeat_at. _is_recently_paused gated closure respawn on
_claim_heartbeat_ttl — the REAPER window (stale_claim_reap_seconds: 600s
default, 1800s on the NAS) — so the parent sat untouched for up to 10-30
minutes before its PM was respawned to close it. The whole chain stalled
behind it.

The race that guard actually protects against (i_am_idle auto-pauses, then
the agent is marked IDLE + its container tears down) is seconds, and the
live-session case is already covered by _is_agent_active. Introduce a
dedicated short debounce (pm_closure_recently_paused_seconds, default 45s)
and gate closure on that instead.

The existing test fixture masked this by setting _claim_heartbeat_ttl to
claim_stale_seconds (180s), not the production reaper value. Fixture now
mirrors production; adds a regression test that a parent paused past the
debounce but within the reaper window respawns immediately.

* feat(gate): post the in-path review verdict on the assembled PR

The in-path gate previously left no trace on the PR it gated — pr_pass /
pr_fail were pure status transitions. Now each verdict is posted as a
GitHub review on the assembled PR itself (server-side, bot account), so
the decision is visible on the very PR the PM merges.

- pr_pass → APPROVE, pr_fail → REQUEST_CHANGES on a cell→root PR.
- The root→master PR ALWAYS gets a plain COMMENT, never APPROVE/REQUEST_
  CHANGES: only the CEO acts on master, so the gate must never leave an
  approval that could satisfy branch protection (letting someone else
  merge) nor a blocking review that could impede the CEO's merge.
- Best-effort and AFTER the DB transition — a GitHub failure is logged,
  never rolls back the gate decision. Reuses git.post_pr_review's existing
  self-review→COMMENT downgrade for the org's own PRs.

Adds _project_slug_for to the ChoreographerHelpers protocol (mypy) and a
unit suite covering event selection, the master-bound COMMENT rule, the
no-PR skip, and failure-swallowing. Docs updated (pr-reviewer, task-tools).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-20 09:27:29 +02:00
Renn F 5902c0fe38 feat(roles): add the read-only pr_reviewer role end-to-end
A global, read-only PR reviewer agent (pr-reviewer-1) that reviews inbound
external/fork PRs and posts one change-request. Wired end-to-end:

- identity: Role.PR_REVIEWER + agent + ROLE_LEVEL (QA-peer) + REVIEWER_ROLES
- lifecycle: CLAIM_RULES + ROLE_TEAM_RULES + a dedicated claim_pr_review /
  post_pr_review verb pair (distinct from QA's) + the pr_review_done action and
  its in_progress->completed transition; give_me_work / i_am_idle gain the role
- role_config: a read-only RoleConfig (allows_write=False)
- journaling: ALL_CELLS read tier so it can read internal intent like QA
- tracing: post_pr_review requires a learning entry; claim_pr_review is waived
- seeds presentation + factory prompt layer + builtin tools + the agentrole
  enum migration (037) + regenerated verb/lifecycle artifacts

Read-only at /app like QA/auditor; default-off — nothing dispatches review work
until external_pr_enabled. Foundation + role-config + enum suites green; ruff +
mypy clean; orchestrator boots.
2026-06-16 10:37:06 +02:00
Renn F 718a16c4f7 Close residual gaps: CEO escalation from blocked, ref repair, reset-script state
Three independent residual hardenings surfaced by the smoke run:

- escalate_to_ceo is now reachable from a blocked task (source widened to
  {awaiting_pm_review, blocked} with a matching status-transition row), so a
  main_pm or board agent has a clean verb to surface a task it cannot resolve
  to the CEO — who can then approve, reject, or cancel it. Regenerated the
  lifecycle artifacts (status-transitions doc + panel JSON) to match.

- WorkspaceService now prunes broken loose refs (.bak debris and any ref whose
  contents are neither an object id nor a symref) before the refresh fetch, so
  a ref left corrupt by an interrupted recovery no longer produces per-operation
  "broken ref" warnings or wedges ref enumeration. Best-effort, file-reads-only.

- The reset script's full-reset Claude-state clear now defaults to the
  replay-state subdirs (projects/, todos/) of the mounted Claude home and
  refuses to clear the home root, so a full reset actually clears conversation
  replay state by default without wiping the host's stored credentials.
2026-06-08 05:17:15 +02:00
Renn F 2631f2647c fix(lifecycle): unblock a never-claimed task to pending, not branchless in_progress
A task blocked before it was ever claimed (a dependency-gated claim that got
escalated) has no branch. Legacy unblock() forced in_progress, which the
dispatcher refuses (state=in_progress but branch_name unset) -> a spawn-refused
loop. Add the blocked->pending transition and route no-branch tasks there so
they are freshly claimed (the claim gate then holds them cleanly while the
dependency is unmet). Branched tasks still resume in_progress. Artifacts
regenerated.
2026-06-04 02:01:09 +02:00
207aaecd72 Feature: lifecycle canonical spec (#14)
* chore: clean make quality baseline on feature/lifecycle-canonical-spec

Three classes of pre-existing issues blocking `make quality`:

1. Alembic migrations 002/009/011 used runtime introspection
   (op.get_bind() + inspect / bind.execute) without guarding for
   offline (--sql) mode. `alembic upgrade head --sql` is part of
   `make quality`; in offline mode `op.get_bind()` returns a
   MockConnection with no inspection system, so the migrations
   crashed before emitting their SQL stubs. Each migration now
   short-circuits or simplifies in `context.is_offline_mode()` —
   live-DB behavior is unchanged.

2. ruff format drift on three files left over from prior in-flight
   edits (choreographer/_impl.py, content_actions.py, and one test
   file). `ruff format` applied.

3. vulture flagged two unused `tb` parameters in async __aexit__
   stubs in test_task_service_lifecycle_misc.py. The parameter is
   protocol-required but unused by the body — renamed to `_tb`
   (vulture treats underscore-prefixed names as intentionally unused).

`make quality` is now green from this branch's HEAD; subsequent
lifecycle-spec work can use it as the per-task gate.

* feat(lifecycle): canonical spec package + Role/Status/TaskType enums

Foundation for the canonical lifecycle/permissions module. Enums
mirror docs/internal/old/workflows/STATUS_TRANSITIONS.md +
PERMISSIONS.md. Tests pin enum membership against both the
predecessor canon and roboco.models.base.TaskType.

* feat(lifecycle): Decision dataclass with allow/reject/tracing_gap constructors

Single rejection shape every consumer maps to its native format
(Envelope, HTTP code, prompt hint). __post_init__ enforces the
allowed/rejection_kind invariants so a malformed Decision can't reach
a consumer.

* fix(lifecycle): tighten Decision invariants per Task 2 review

Two reviewer findings on the Task 2 Decision dataclass, addressed
in one commit:

1. The docstring promised `allowed=True ⇒ rejection_kind is None
   AND missing == [] AND remediate is None`, but __post_init__ only
   checked the rejection_kind half. A caller could construct an
   allow-shaped Decision with stale missing/remediate fields and
   sneak it past validation. Tighten __post_init__ to enforce the
   full invariant. Add a regression test.

2. tracing_gap defensively copies the missing list (`list(missing)`)
   to isolate the stored list from later caller-side mutation, but
   no test pinned this. Add a regression test that mutates the source
   list after construction and asserts the stored list is unchanged.

Issue 2 from the same review (mutable list vs tuple for `missing`)
is a broader design call deferred until consumers exist; the
defensive copy is sufficient until then.

* feat(lifecycle): Precondition/ActionSpec/IntentSpec/StatusTransition dataclasses

The four dataclasses that hold the canonical tables. ActionSpec and
StatusTransition are direct ports of pre-gateway PERMISSIONS.md +
STATUS_TRANSITIONS.md rows. IntentSpec is the gateway-only addition:
each gateway intent verb declares which atomic actions it composes.

* feat(lifecycle): _STATUS_TRANSITIONS table + STATUS_GRAPH view

Direct port of STATUS_TRANSITIONS.md. Every transition records its
trigger action and (optionally) a role constraint. STATUS_GRAPH is
the precomputed source→{targets} view callers use for reachability
checks.

* fix(lifecycle): pin role_constraint values + clarify Task-5 handoff

Two reviewer findings on Task 4 _STATUS_TRANSITIONS, addressed in
one commit:

1. The original Task-4 tests verified (source, target) pairs but
   not role_constraint contents. A typo in a single role name (e.g.
   forgetting MAIN_PM from escalate_to_ceo) would have slipped past
   them silently. Add test_status_transitions_role_constraints_match_canon
   pinning every non-None constraint and the cancel-block invariant.

2. role_constraint=None on the `claim` rows from PENDING and
   NEEDS_REVISION was load-bearing — it is the explicit handoff
   point between the StatusTransition table (state machine layer)
   and CLAIM_RULES (per-role claim authority, lands in Task 5).
   The original inline comment said this in passing; expand it so
   the design choice is unmissable for a stranger reading just
   spec.py.

* feat(lifecycle): _ATOMIC_ACTIONS + CLAIM_RULES + ROLE_TEAM_RULES tables

Direct port of PERMISSIONS.md. Every task management tool gets an
ActionSpec with allowed_roles, source_statuses, target_status,
self_review_block, and needs_team_match flags. CLAIM_RULES maps each
Role to the statuses they can claim from. ROLE_TEAM_RULES is the
per-slug team restriction.

* fix(lifecycle): tighten ActionSpec contracts per Task 5 review

Three reviewer findings on Task 5's _ATOMIC_ACTIONS table, addressed
in one commit:

1. set_plan.source_statuses widened to {CLAIMED, IN_PROGRESS} but
   every existing caller (i_will_work_on / i_will_plan compositions)
   runs set_plan while CLAIMED, between claim and start. Narrow to
   {CLAIMED} only. If a future "edit plan mid-flight" feature lands,
   widen explicitly with test coverage at that time.

2. needs_team_match was set True only on claim/qa_pass/qa_fail/
   docs_complete. Defense-in-depth says every role-scoped task
   action should re-assert team match (don't rely on the inheritance
   chain through assigned_to alone). Flip to True on: start,
   set_plan, block, pause, submit_verification, submit_qa,
   submit_pm_review, complete, create_subtask. Leave False on
   board/CEO actions and PM cross-cell interventions (unblock,
   resume, cancel) where the cross-cell semantics are intentional.

3. claim.source_statuses is intentionally a SUPERSET of any single
   role's CLAIM_RULES allowance (the table holds the union; CLAIM_RULES
   holds the per-role authority). Add an inline comment above the
   claim ActionSpec so a future reader doesn't conclude the two
   tables disagree — they don't, they encode overlapping facts at
   different grains.

* feat(lifecycle): _INTENT_VERBS table — every gateway verb declared

Each gateway intent verb is now a named composition of atomic actions
plus optional side effects. i_will_work_on = (claim, set_plan, start);
i_am_done = (submit_verification, submit_qa); open_pr is pure side
effects (push_branch, create_pr); etc.

* fix(lifecycle): widen block.allowed_roles to include QA + Documenter

Task 6 review caught a role-set inconsistency: i_am_blocked.allowed_roles
admits dev/QA/doc, but the underlying block.allowed_roles only allowed
dev+PM. Result: a QA or documenter calling i_am_blocked would pass the
IntentSpec gate and then be rejected by the composed ActionSpec gate
when Task 7 wires can_invoke_intent.

Widen block to include QA + Documenter. The semantic case is sound: a
QA reviewing a task can discover an external blocker; a documenter
writing docs may need PM intervention. Predecessor PERMISSIONS.md
restricted block to dev+PM, but with the gateway exposing i_am_blocked
to all worker roles, the underlying atomic must agree.

The deeper unclaim/escalate_up "imperative verb" concern from the same
review (composes=() but mutates state) is deferred to Task 8 where the
validator design lands.

* feat(lifecycle): public lookup functions + Context + preconditions

can_claim, can_invoke_action, can_invoke_intent, valid_next_verbs,
composed_actions_for, intents_for_role, status_after — the entire
public surface every consumer will use. Context carries the
caller-supplied state preconditions need (plan, journal-decision
flag, etc.). Preconditions for plan/commits/no_pr/ownership are
declared once and wired into the relevant IntentSpecs.

* fix(lifecycle): wire PRECONDITION_OWNERSHIP through Context.actor_id

Task 7 review found _p_owns_task reads agent.id but every call site
passes None for the agent arg. Result: getattr(None, "id", object())
returns a fresh sentinel, task.assigned_to == <sentinel> is always
False, and open_pr / i_am_done would reject every owner the moment
Task 9 wires consumers.

Fix: thread identity through Context.actor_id (new UUID field) and
rewrite _p_owns_task to read from the context. Both call sites already
pass the Context — no signature changes elsewhere. Add green-path
test exercising the owner-can-open-pr case the existing tests
missed (the Task 7 plan only tested precondition-failure paths,
which masked the bug).

Plus surface hygiene: STATUS_GRAPH, CLAIM_RULES, ROLE_TEAM_RULES,
and the four PRECONDITION_* constants are now in
roboco.lifecycle.__init__.__all__ so consumers in Tasks 8/9 don't
depend on the implicit `from roboco.lifecycle.spec import ...`
backdoor.

* feat(lifecycle): import-time self-consistency validators

10 validators run at module import; first failure raises
LifecycleSpecError and prevents the package from loading. Covers
status enum coverage, reachability, terminal exits, intent
compositions, status chain consistency, claim-rule role/status
coverage, self-review symmetry, team-rule slug existence, and
StatusTransition action references.

* fix(lifecycle): close validator gaps; resolve BACKLOG-claim and submit_qa IN_PROGRESS-shortcut ambiguity

Three reviewer follow-ups on Task 8's _validate.py, plus two real
data corrections the new action-target-reachability validator
surfaced.

1. Design spec §9 calls for "every ActionSpec.target_status, when
   set, is reachable from each source_status via STATUS_GRAPH" —
   missing from Task 8's 10 validators. Add
   _check_action_target_reachable_from_source.

2. _check_role_team_rules_slugs verified slug existence in
   AGENT_UUIDS but NOT that the cell team in ROLE_TEAM_RULES
   matches the seed. Add _check_role_team_rules_team_match,
   scoped to non-None entries only — None means "exempt from
   team-match enforcement" (cross-cell roles), not "no team in
   org chart".

3. test_validators_pass_on_real_spec was ceremonial. Add
   test_run_all_validators_raises_on_unknown_intent_action,
   a deliberate-break regression that monkeypatches _INTENT_VERBS
   to inject a fake action and asserts LifecycleSpecError raises.

The new action-target-reachability validator caught two real
data inconsistencies between the predecessor canon docs and the
spec tables:

A. claim.source_statuses listed BACKLOG and CLAIM_RULES[*PM]
   listed BACKLOG, but STATUS_GRAPH[BACKLOG] = {PENDING, CANCELLED}
   only. Resolution: PMs use the explicit \`activate\` action to
   move BACKLOG → PENDING, then claim from PENDING. Drop BACKLOG
   from claim.source_statuses and CLAIM_RULES.

B. submit_qa.source_statuses listed IN_PROGRESS, but
   STATUS_GRAPH[IN_PROGRESS] does NOT include AWAITING_QA. The
   intent verb i_am_done composes (submit_verification, submit_qa)
   which forces IN_PROGRESS → VERIFYING → AWAITING_QA — no
   shortcut. Drop the stale IN_PROGRESS entry from
   submit_qa.source_statuses.

Both corrections tighten the canonical state machine to a strict
no-skip transition graph. Pre-gateway PERMISSIONS.md/STATUS_TRANSITIONS.md
disagreements are resolved here; spec.py is the canon now.

* feat(gateway): Envelope.from_decision maps lifecycle Decisions to envelopes

Single shape adapter so verb bodies stop hand-composing rejection
envelopes. Each rejection_kind maps to a specific envelope flavor;
'self_review' folds into 'not_authorized' with a parenthetical hint;
constructing from an allow Decision raises (programmer error).

* feat(gateway): VerbRunner for atomic composed-action dispatch

Wraps spec.composed_actions_for(intent) in session.begin_nested()
so mid-sequence failures roll the DB back. Side effects run AFTER
the savepoint commits. Each atomic action name dispatches to a
TaskService method via a single, exhaustive _dispatch_atomic
mapping. New verbs slot in by adding an IntentSpec entry + a
_dispatch_atomic case if a new atomic is needed.

* refactor(gateway): i_will_work_on uses spec.can_invoke_intent + VerbRunner

Replace the bespoke status-branch dispatcher in i_will_work_on with the
spec-driven flow: load task -> load agent -> build spec.Context ->
spec.can_invoke_intent (and spec.can_claim for per-role status authority)
-> Envelope.from_decision on rejection -> VerbRunner.run_intent on success.

The _i_will_work_on_pending, _i_will_work_on_claimed,
_i_will_work_on_needs_revision, and _start_failed_envelope helpers are
removed; the runner replaces them. Two narrow verb-body re-entry blocks
remain for behaviors the spec does not yet model:

  1. in_progress + same agent -> idempotent heartbeat-only return
  2. claimed + same agent -> _resume_from_claimed (set_plan + start)
     to recover from a stuck mid-claim crash without re-running claim
     against a state the spec excludes.

The behavioral claim guards (already_active / paused / sibling_sequence)
also stay imperative for now -- they're not in the spec yet and migrate
into spec.extra_preconditions in a later task. Per-role claim authority
is enforced via spec.can_claim because the atomic claim action's
source_statuses are the union across roles; CLAIM_RULES narrows.

Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against every (role x status x task_type='code') combo (112 rows) and
asserts the envelope error matches the spec's Decision (or can_claim's
Decision when the intent gate passes but per-role claim authority does
not). This is the contract that makes spec/verb drift impossible.

Existing tests updated where rejection-message text changed (the spec
now produces the messages, e.g. "role 'cell_pm' may not call
'i_will_work_on'" instead of "PM cannot execute code") or where the
spec's stricter view ("invalid_state" -> "not_authorized" for a dev
trying to claim awaiting_qa) is more accurate. Test fixtures were
updated to wire task.session.begin_nested as a proper async context
manager (required by VerbRunner) and to set agent_for().id so runner-
driven calls line up with assert_awaited_with(task_id, agent_id).

* refactor(lifecycle): push CLAIM_RULES enforcement into can_invoke_action

Task 11's i_will_work_on migration had to call spec.can_claim()
separately after spec.can_invoke_intent() because the claim action's
source_statuses is the union across all claim-eligible roles —
can_invoke_intent alone would let a developer pass for claiming
awaiting_qa (a QA-only state).

The retrofit pattern would repeat in every claim-composing verb
(i_will_plan, claim_review, claim_doc_task). Push the per-role
narrowing inside can_invoke_action when the action is "claim",
using the same not_authorized vs invalid_state disambiguation
can_claim already implemented (status-reserved-for-another-role
returns not_authorized; status-no-role-can-claim returns
invalid_state). Extracted the body to _check_claim_rules_narrow
to keep can_invoke_action under xenon's complexity threshold.

Update _i_will_work_on_gate to drop the redundant spec.can_claim
call. Update test_consumer_parity.py to assert only against
can_invoke_intent's Decision.

Tasks 12-22 will inherit the cleaner pattern: spec.can_invoke_intent
is the single gate; verb bodies don't need per-action retrofits.

* refactor(gateway): i_will_plan uses spec.can_invoke_intent + VerbRunner

Migrates i_will_plan to the spec-driven pattern Task 11 set up for
i_will_work_on. The verb body now: (1) loads task + agent, (2) builds
Context, (3) checks idempotent/recovery re-entry, (4) calls
spec.can_invoke_intent, (5) returns Envelope.from_decision on
rejection, (6) delegates composition to VerbRunner. The
_i_will_plan_* helpers are removed — the runner replaces them.

Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against every (role × status × task_type) combo and asserts the
envelope matches spec.Decision.

* refactor(gateway): delegate uses spec.can_invoke_intent for role/state gate

Migrates delegate to the spec-driven role/state gate. The chain
validation (main_pm->cell_pm, cell_pm->its team's devs), the
assignee-vs-task_type rule (Cell PMs receive planning-typed only),
the enum coercion, and the parent-lifecycle/cap guards STAY in the
verb body — they encode delegate-specific semantics the spec
doesn't model.

Parity test in tests/lifecycle/test_consumer_parity.py asserts the
spec's role+state rejection is correctly surfaced. Chain/assignee
rejections continue to be tested in test_choreographer_pm_extras.

* refactor(gateway): open_pr uses spec.can_invoke_intent + VerbRunner

Migrates open_pr to spec-driven gating. The spec's
extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS,
PRECONDITION_NO_PR) handle all three precondition checks; the verb
body delegates side-effect dispatch (push_branch, create_pr) to
VerbRunner.

Idempotent re-entry retained: an open_pr call against a task that
already has a PR (and the caller owns it) returns OK without
re-opening, rather than the tracing_gap the spec would otherwise
produce. This preserves agent ergonomics — two calls in a row
shouldn't surface a misleading "no_prior_pr" hint.

Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against representative (status x commits x pr_number) combos and
asserts the envelope matches spec.Decision.

* refactor(gateway): i_am_done uses spec.can_invoke_intent + VerbRunner

Migrates i_am_done to spec-driven gating. The spec's
extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS)
handle ownership and commit-count checks; VerbRunner dispatches
the (submit_verification, submit_qa) atomic chain.

The tracing-gate preconditions (progress entry, journal:reflect,
acceptance criteria) and the field-level submit-qa gates stay in
the verb body — they model gates the spec doesn't yet cover.
Defense-in-depth: those gates run after the spec accepts the
ownership/commits checks.

Parity test in tests/lifecycle/test_consumer_parity.py runs the
verb against (role × status × ownership × commits) and asserts
the envelope matches spec.Decision.

* refactor(gateway): i_am_blocked uses spec.can_invoke_intent + VerbRunner

Migrates i_am_blocked to spec-driven gating. The journal:struggle
write stays in the verb body (it's a side effect outside the
lifecycle action). VerbRunner dispatches the `block` atomic action
via task_service.escalate.

Parity test in tests/lifecycle/test_consumer_parity.py.

* refactor(gateway): unclaim and resume use spec.can_invoke_intent

Migrates both verbs to the spec-driven gate. unclaim's verb body
keeps its dispatch (task.unclaim_for_agent) because composes=();
resume goes through VerbRunner with composes=("resume",).

The reassignment-rejection branch (introduced in 19f27b4 for the
2026-05-08 trace's "not your claim" case) stays - the spec doesn't
model "task got reassigned out from under you by an upstream verb,"
and the existing envelope text ("current owner: X - call
give_me_work() to find your current work") is the load-bearing
hint that fixed the original bug. Extracted the shared branch into
_reassigned_rejection / _ReassignedCtx so both verbs reuse it
without duplicating the envelope construction.

Parity tests in tests/lifecycle/test_consumer_parity.py.

* refactor(gateway): complete uses spec.can_invoke_intent at the dispatcher

Migrates the top-level `complete` dispatcher to gate role/state via
spec.can_invoke_intent before routing to cell_pm_complete or
main_pm_complete. The two lower-level methods keep their existing
PR-merge / CEO-escalation logic and pre-flight guards (those model
journal:decision preconditions and PR-mergeability checks the spec
doesn't model yet).

The runner pattern is NOT applied here — `complete` has two divergent
runtime paths (Cell PM merges leaf into parent branch; Main PM opens
master PR + escalates to CEO) that don't fit the runner's
single-composition model. Verb-body-owns-dispatch is the right
pattern.

Parity test in tests/lifecycle/test_consumer_parity.py runs the
verb against (role × status) combos and asserts the dispatcher's
spec rejection is correctly surfaced.

* refactor(gateway): escalate_up, escalate_to_ceo, submit_up use spec.can_invoke_intent

Migrates the three PM-side escalation/submission verbs to
spec-driven role/state gating. The verb-specific guards
(journal:decision, escalation_target configured, _submit_up_guard's
ownership + notes-length + subtasks-terminal) STAY in the verb body
- the spec doesn't model these.

escalate_up has composes=() so the verb body owns dispatch via
task.escalate. escalate_to_ceo and submit_up route their
compositions through VerbRunner.

Parity tests in tests/lifecycle/test_consumer_parity.py.

* refactor(gateway): qa.py + doc.py role mixins use spec.can_invoke_intent

Migrates the five QA + Documenter verbs (claim_review, pass_review,
fail_review, claim_doc_task, i_documented) to spec-driven gating.

The self-review block lives at the atomic-action layer
(_ATOMIC_ACTIONS["qa_pass"|"qa_fail"|"docs_complete"].self_review_block=True)
and naturally fires when the verb body builds a Context with
actor_slug==original_developer_slug. No verb-body retrofits needed.

The verb-specific helpers (_verify_qa_owner, _qa_pass_gate_check,
_check_i_documented_inputs) STAY — they encode notes-length /
journal:learning / files-list / qa_evidence_inspected gates the
spec doesn't model.

claim_review and claim_doc_task own dispatch via task.qa_claim /
task.doc_claim respectively (not the runner) because those
specialized claim methods keep status at AWAITING_QA /
AWAITING_DOCUMENTATION, which is what the downstream qa_pass /
qa_fail / docs_complete source-status requirement expects.
The spec gate still validates role + claim source-status + task_type
before dispatch.

pass_review / fail_review / i_documented route their compositions
(qa_pass / qa_fail / docs_complete) through VerbRunner.run_intent
inside a savepoint.

Parity tests in tests/lifecycle/test_consumer_parity.py for all
five verbs.

* fix(lifecycle): claim_review and claim_doc_task have empty composes

Tasks 21-22 surfaced a real spec/runtime mismatch: both verbs were
declared composes=("claim", "start"), but the actual implementation
uses task.qa_claim / task.doc_claim which intentionally keep status
at AWAITING_QA / AWAITING_DOCUMENTATION. If the runner ever ran the
declared composition, it would transition the task to CLAIMED then
IN_PROGRESS, breaking the source-status invariants of qa_pass,
qa_fail, and docs_complete.

The spec is the canon — align it to the runtime. composes=() means
"verb body owns dispatch" (same pattern as escalate_up and unclaim).
The spec gate still validates role + AWAITING_QA / AWAITING_
DOCUMENTATION source-status via the role's CLAIM_RULES narrowing,
enforced through special handling in can_invoke_intent, so role/state
safety is preserved.

* refactor(gateway): role_config flow lists derived from spec.intents_for_role

Hand-maintained _DEV_FLOW etc. tuples replaced with calls into the
spec. Adding/removing a role from an IntentSpec.allowed_roles now
automatically updates the MCP manifest. The spec is the canon;
role_config becomes a thin shim that adds the do-tool / write /
subagent / description metadata the spec doesn't carry.

* feat(lifecycle): generators + make lifecycle for deterministic artifact regen

Renders intent-verbs.md, status-transitions.md, panel/lib/lifecycle.json,
and per-role agents/prompts/_generated/lifecycle-{role}.md fragments
from the canonical spec. `make lifecycle` runs the regenerator;
deterministic output enables CI to gate on `git diff --exit-code` after
running it. The agent prompt fragments will be injected at the top of
each role's system prompt (Task 25) so agents see the same verbs the
gateway accepts.

* feat(lifecycle): inject generated prompt fragments + CI drift gate

Each agent's system prompt now starts with the spec-generated
'verbs available to your role' fragment. CI runs make lifecycle
and fails if regeneration produces a diff — drift between spec
and artifacts cannot land on master.

* refactor(gateway): delete verb_gates.py — superseded by lifecycle.spec

verb_gates.is_verb_allowed and verb_gates.valid_next_verbs are now
spec.can_invoke_intent(...).allowed and spec.valid_next_verbs.
Importers updated to consume the canonical spec module directly.
tests/unit/gateway/test_verb_gates.py removed — coverage lives in
tests/lifecycle/test_spec.py.

envelope.with_introspection wraps spec.valid_next_verbs with role-string
coercion + best-effort try/except so malformed task fixtures (AsyncMock
status) and unknown role strings still yield [] instead of raising —
preserves the legacy verb_gates contract.

content_actions content-tool RBAC (commit/notify) is now a pair of
explicit role frozensets in this file. These are content tools, not
lifecycle intents, so they intentionally do NOT live in spec._INTENT_VERBS.

Two existing introspection tests asserted "commit" in valid_next_verbs;
fixed to assert open_pr/i_am_done — commit is correctly absent under
the canonical spec because it is a do-server content tool, not a flow
intent verb.

* refactor(gateway): collapse scattered role constants into spec

The pm_cannot_execute_code_guard and role_typed_claim_guard guards
both modeled rules the spec now handles via can_invoke_action's
CLAIM_RULES narrowing and ActionSpec.allowed_task_types. Drop them
from claim_guards.py — the choreographer's existing skip-flags on
_run_claim_guards are now permanent: those guards no longer fire.
Simplify _run_claim_guards's signature accordingly.

The concurrency-invariant guards (already_active_guard,
paused_tasks_guard, sibling_sequence_guard) STAY — the spec doesn't
model these system-level invariants. sibling_sequence_guard's loop
body extracted into _earlier_blocking_sibling helper to keep the
slimmed module under xenon's --max-modules A average.

* refactor(enforcement): task_lifecycle becomes a thin view of lifecycle.spec

VALID_TRANSITIONS and ROLE_RESTRICTED_TRANSITIONS are now derived
from roboco.lifecycle.spec — no independent tables. The 433-line
file collapses to ~30 lines of view definitions; future changes
go in spec.py. Helper functions exported by the legacy module are
preserved as thin wrappers so existing consumers don't need to
change their imports today.

A small _LEGACY_OPERATIONAL_EDGES table sits alongside the
spec-derived view to cover transitions the runtime exercises but
the spec has not yet absorbed (voluntary unclaim, reaper sweep,
PM-direct completes from in_progress, parallel-doc-PR developer
trigger). It is fenced and clearly documented; once those callers
are migrated to spec-driven dispatch the constant goes empty and
the file collapses to a pure view.

A test in test_task_service_lifecycle_misc.py was rewritten: the
predecessor asserted CEO-only authority over awaiting_ceo_approval
cancels (legacy table behavior), but the canonical spec authorizes
{CELL_PM, MAIN_PM, CEO} uniformly across all non-terminal cancel
sources. The test now exercises the broader spec-defined cascade.

* feat(lifecycle): UNMIGRATED guard pins known-debt consumers

Two pieces of debt surfaced during Task 28's collapse of
enforcement/task_lifecycle.py: (1) ~11 operational edges still in
the shim's _LEGACY_OPERATIONAL_EDGES because the spec's
_STATUS_TRANSITIONS doesn't yet model them; (2) role-gate
disagreements in _LEGACY_ROLE_GATES that the spec disagrees with.

UNMIGRATED is the named-debt set; KNOWN_UNMIGRATED_CONSUMERS pins
the catalog so a contributor adding a new entry must update both
sides. Validator (_check_unmigrated_is_subset) fires at import if
they drift. Test pins the current entries.

Phase 3's terminal invariant is `UNMIGRATED == frozenset()` —
expected when both legacy data carriers fold into spec, at which
point the assertion becomes a permanent regression guard.

* test(lifecycle): tier 3 end-to-end real-DB happy paths

Eight integration tests covering every major lifecycle path:
dev (pending → awaiting_qa), QA pass, QA fail, doc handoff,
Cell PM complete, Main PM escalate-to-CEO, block+unblock,
pause+resume. Each test drives the spec → choreographer →
TaskService → DB stack with only the git layer mocked. Catches
"spec says X, DB constraint says Y" mismatches the unit-tier
parametrized parity suite cannot detect.

* test(lifecycle): tier 4 smoke replay — pin known-bug shapes after spec migration

Synthesized fixture covering the 9 bugs from the 2026-05-08
audit-log trace + the 2 from the 2026-05-09 follow-up trace. Each
record documents (verb, role, task setup, expected post-fix
envelope shape, fix commit, spec invariant). The replay test
parametrizes over the records and asserts the spec / choreographer
behavior now matches the post-fix expectation — locks in the
fixes as permanent regressions.

The original audit log was wiped during cleanup; the fixture is
a documented synthesis, not a verbatim capture. The bug list is
faithful to the prior session's analysis of the trace.

* fix(orchestrator): silence dev-dispatcher noise for non-dev-lane tasks

Dev dispatcher fetched all pending/claimed/in_progress tasks regardless
of assignee role and warned 'role/task_type mismatch' on each pass when
it found cell_pm/main_pm/product_owner/etc. tasks — those belong to
_dispatch_pm_work, not this lane. The 30s warning loop showed up
prominently in the 2026-05-10 smoke run.

Filter at the lane boundary: silently skip when assignee role is not
developer/documenter/unknown. The D-49 misassignment warning still
fires for the legitimate cases (developer assigned a documentation
task, etc.).

* fix(gateway,prompts): unblock the three smoke-run dead-ends

Three issues surfaced by the 2026-05-10 smoke run, fixed together
because they're all blockers for end-to-end task completion:

1. Acceptance-criteria tracing gate was unsatisfiable. Nothing in the
   codebase writes to task.acceptance_criteria_status, so
   _check_acceptance_criteria always returned every criterion as
   missing. Treat a reflect note as the addressing artifact: when the
   agent has written one, the gate clears. Per-criterion citation via
   acceptance_criteria_status is still honored when populated, so the
   schema stays available for future per-criterion tracking.

2. Cell PM runaway re-decomposition. On every wake-up be-pm
   re-decomposed its parent task without checking for existing
   children, producing duplicate dev subtasks. cell_pm.md now teaches
   'list children before delegating' and 'one dev subtask is usually
   enough — QA/Documenter/PM-merge engage automatically'. Added
   anti-pattern entries for re-decomposition and over-decomposition.

3. Main PM exit/respawn loop on claimed-state tasks. The model
   cycled through delegate/resume/escalate/unblock looking for a verb
   that worked on 'claimed', and got cleanly rejected by every one.
   The right verb is i_will_plan (it composes claim+set_plan+start
   and resumes from claimed). main_pm.md now spells this out
   explicitly with a worked example of which verbs reject and why.

* feat(prompts): restore pre-gateway lifecycle scaffolding across all 6 roles

The gateway migration shrank role prompts from ~50 lines to ~15
(commit 534152c for dev; analogous shrinks for qa/doc/cell_pm/main_pm/board
in e12a596, 05ac832, 8dc381b). The verb surface got cleaner but the
prescription for using verbs through the lifecycle disappeared. The
2026-05-10 smoke run surfaced the regression: agents thrash through
verbs hoping one fits, journal sparsely, skip the dev reflect note,
and (for cell PMs) re-decompose on every wake-up.

Each role prompt now restores three sections that the pre-gateway
versions had:

1. State -> Verb table — what to call when respawned in each
   lifecycle status. Eliminates the verb-cycling antipattern: the
   agent looks up its current status and calls the one verb that
   transitions out of it.

2. Mandatory pre-handoff checklist — explicit walk-through of the
   gates the next verb will check, ordered so the agent fixes the
   missing piece before retrying:
   - developer: 7 items before i_am_done
   - qa: 8 items before pass/fail (incl. self-review forbidden,
     read dev journal not just diff, name artifact per criterion)
   - doc: 7 items before i_documented
   - cell_pm: 7 items before submit_up (incl. integration green)
   - main_pm: 7 items before complete(root)
   - board: separate checklists for escalate_to_ceo (PO/HoM) and
     reflect-note quality (Auditor — its only output)

3. Journaling cadence — when to use each of the five scopes
   (note/decision/struggle/learning/reflect). The pre-gateway
   prompts named all five scopes with role-specific examples;
   the post-gateway prompts mention 'reflect' once and skip the
   rest. Restored across every role.

Plus restored the load-bearing rules that got dropped:

- Cell PM: 'A SINGLE subtask flows through dev -> QA -> doc ->
  PM-merge. DON'T split into per-role subtasks.' This is exactly
  what be-pm violated in the smoke run, creating duplicate
  'branch naming subtask' / 'PR workflow subtask' / etc.
- QA + Doc: 'read the dev's journal, not just the diff' — pre-
  gateway forced this via roboco_journal_read_team; post-gateway
  the inline data exists but the agent isn't told to use it.
- Developer: 'every acceptance criterion gets a citation in the
  reflect note' — pairs with the tracing-gate change in 75b667d
  where the reflect note is treated as the addressing artifact.

* feat(foundation): bootstrap foundation/identity.py with Role/Team/RoleLevel

Phase 1 task 1 of the foundation canonicalization plan
(docs/superpowers/specs/2026-05-10-foundation-canonicalization-design.md).

Three enums, no consumers yet — separate tasks migrate the existing
forks (models.base.AgentRole, lifecycle.spec.Role, agents_config role
sets, services/permissions.PM_ROLES) onto this canonical surface.

* feat(foundation/identity): add AGENTS catalog (single source for slug->role+team+UUID)

Resolves head-marketing.team drift (spec §5.1) by setting Team.BOARD
authoritatively. Team.MARKETING remains in the enum for legacy seed
data but no agent claims it; flagged for removal in cleanup.

* feat(foundation/identity): add role-sets + ROLE_LEVEL hierarchy

* feat(foundation/identity): add lookups + public API re-exports

* feat(foundation): import-time validators (uniqueness, role coverage, role-level)

* chore(foundation): verify+align postgres agentrole/team enums with foundation/identity

scripts/verify_postgres_enums.py reads the live agentrole+team enums
from postgres (via asyncpg using roboco.config.settings.database_*)
and compares them against the foundation Role+Team enums. Exits 0 on
match, 1 on drift (with a per-side diff), and 1 with a clear message
if postgres is unreachable so callers like make foundation-check can
treat that as a skip.

alembic/versions/012_align_agentrole_team_with_foundation.py is the
forward-only safety-net migration. It runs ALTER TYPE agentrole ADD
VALUE IF NOT EXISTS 'system' (idempotent on postgres >= 9.6) so any
DB without the recently-added Role.SYSTEM sentinel gets it on next
upgrade. Postgres has no DROP VALUE primitive without a destructive
type recreation, so foundation keeps legacy values (e.g. Team.MARKETING)
to absorb the inverse direction; the migration's downgrade is
intentionally a no-op.

Local verification deferred: postgres is not reachable from this
workstation (role 'roboco' does not exist), so the script could not
confirm the live enum shape. The migration is idempotent and runs
unconditionally on the next alembic upgrade head, and whoever next
runs make foundation-check against a live DB will get the post-migration
proof of alignment.

* refactor(lifecycle): re-export Role from foundation.identity (single source)

* refactor(models): re-export AgentRole and Team from foundation.identity

Removes the parallel Team and AgentRole StrEnum definitions in
models/base.py. They are now bound to roboco.foundation.identity.Role
and roboco.foundation.identity.Team respectively, so AgentRole IS
identity.Role (same Python class object). SQLAlchemy column types
bound as sa.Enum(AgentRole, name='agentrole') continue to work because
identity is preserved across import paths.

Note: foundation.Team drops the legacy 'fullstack' member that lived
on models.base.Team. The two _resolve_team_dir tests that used
Team.FULLSTACK to exercise the 'fullstack' branch now pass the literal
string 'fullstack' instead — same code path, no enum-membership coupling.

Adds two identity assertions to tests/foundation/test_role_reexport.py
verifying AgentRole is identity.Role and Team is identity.Team.

* fix(foundation): correct Team enum — add FULLSTACK, remove QA

The original plan's audit incorrectly identified the models/base.Team
membership. Actual original was 7 values: backend, frontend, ux_ui,
fullstack, main_pm, board, marketing. My plan replaced fullstack with
qa and added system — but qa was never a team (only a role).

Postgres team enum has fullstack (alembic 009), and services/task.py:675
+ services/git.py:779 branch on the literal "fullstack". Without
foundation.Team.FULLSTACK, any Project row with assigned_cell="fullstack"
would fail to round-trip through the SQLAlchemy ORM.

This correction:
- Adds FULLSTACK; removes QA from foundation.Team
- Updates the 8-value test expected set
- Restores tests/integration/test_task_service_misc.py to use Team.FULLSTACK

* refactor(agents_config): derive AGENT_ROLE_MAP/AGENT_TEAM_MAP/CELL_MEMBERS from foundation

* refactor(roles): canonicalize role-sets via foundation.identity

- agents_config.PM_ROLES (5-role: PMs + board + CEO) renamed to
  TASK_CREATOR_ROLES; the name PM_ROLES is reserved for the canonical
  2-role set (CELL_PM + MAIN_PM) defined in foundation.identity.
- agents_config._BOARD_ROLES aliased to foundation.BOARD_ROLES (drops
  main_pm from the set; board A2A handler updated to keep allowing
  board -> main_pm direct messaging via explicit branch).
- services/permissions.PM_ROLES (2-role) re-exported from foundation.

Closes the silent semantic divergence flagged in spec section 3 (HIGH severity).

* refactor(seeds,orchestrator): derive agent catalogs from foundation

- seeds/initial_data.AGENT_UUIDS derived from foundation.AGENTS.
- DEFAULT_AGENTS row generation pulls slug+role+team+id from foundation;
  per-agent presentation strings (display name) stay in this file in
  _AGENT_PRESENTATION dict. The system sentinel remains a literal with
  team=None because the postgres `team` enum has no 'system' value.
- runtime/orchestrator._AGENT_TEAM_MAP and the cell-prefix table replaced
  with foundation.team_for_slug. _AGENT_TEAM_MAP is now a derived ClassVar
  covering every slug (not just management).
- head-marketing.team resolved to "board" (was "marketing" in seed +
  orchestrator, "board" in agents_config — three-way drift, now unified).
- ceo.team resolved to "board" (was None in seed; foundation declares
  board membership so the seed-bootstrapped DB row now reflects that).
- Adds tests/foundation/test_seed_orchestrator_parity.py — gate against
  future drift between seed/orchestrator and foundation.

Closes the identity sub-phase. Adding an agent edits exactly one file:
foundation/identity.py:AGENTS.

* feat(foundation/policy): task_completeness rules + denylist

Implements spec §5.2: field-level completeness rules at create/delegate
time, plus the denylist that catches the literal placeholder string from
the deleted services/task.py:5061-5062 silent fallback ("completed and
reviewed by assignee" — agents copy-paste this from old logs).

CompletenessSpec is data; check() is a pure function; field_hints map
gives the agent the literal answer key for each missing field.

* feat(envelope): add incomplete_input envelope kind for interrogation pattern

Sister to tracing_gap; distinct error code lets agent prompts teach
incomplete_input handling separately from tracing-gap recovery. Carries
missing + field_hints + remediate for the spec §5.2.1 interrogation
pattern; Task 19 will wire the gateway delegate verb to use it.

* feat(foundation/task_completeness): auto-fill helpers (team, priority, parent)

* feat(api/schemas): DelegateRequest enforces TASK_AT_CREATE constraints

Removes silent defaults for nature/task_type/estimated_complexity;
adds min_length=20 to description; requires non-empty acceptance_criteria.
Mirrors foundation.policy.task_completeness.TASK_AT_CREATE so under-filled
delegate calls fail at the request boundary (422) instead of being silently
papered over downstream.

Tests touching delegate calls updated to pass the now-required fields.

* feat(models/task): TaskCreate + TaskCreateRequest enforce TASK_AT_CREATE

* feat(api/schemas): TaskUpdate rejects blanking acceptance_criteria

Golden Rule preservation — acceptance_criteria cannot be set to []/None
via PATCH. Pydantic field min_length doesn't catch explicit None, so a
model_validator(mode='before') guards the patch payload.

* fix(services/task): delete silent acceptance_criteria fallback (skeleton-task root cause)

The fallback at services/task.py:5061-5062 silently replaced empty
acceptance_criteria with ['completed and reviewed by assignee'] -
the proximate cause of every skeleton task in the 2026-05-10 smoke
run. Removed; create_subtask now invokes foundation.policy.task_completeness
and raises TaskCompletenessError on missing fields (spec section 5.2).

Two existing transition tests relied on a 1-char description default
that the new completeness check rejects (description min_length=20);
both updated to pass an explicit valid description. New integration
test pins the rejection contract (empty list + legacy phrase both
raise).

Companion code path at services/gateway/choreographer/_impl.py:1852
(the upstream `or []` collapse) is fixed in the next task.

* fix(gateway/delegate): use task_completeness + Envelope.incomplete_input

Replaces the `acceptance_criteria=inputs.acceptance_criteria or []`
collapse at _impl.py:1852 with a foundation.policy.task_completeness
check that rejects empty / placeholder input via
Envelope.incomplete_input — the spec section 5.2.1 interrogation pattern.

Auto-fill helpers (fill_team_from_assignee + fill_priority_from_parent)
fill the unambiguous fields before the check, then anything still
missing surfaces as a structured rejection with field_hints; the agent
gets a literal answer key for what to provide on retry.

DelegateInputs gains an explicit `nature` field (no default) so the
HTTP boundary can thread DelegateRequest.nature through to the
choreographer. Route handlers (flow_cell_pm, flow_main_pm) forward it.
The hardcoded TaskNature.TECHNICAL fallback in _create_subtask_from_inputs
is removed; the helper now coerces inputs.nature to the enum or raises
TaskCompletenessError if a non-gateway caller bypassed the check.

Closes the gateway-side path to skeleton tasks. Service-layer raise
(Task 18) remains as defense-in-depth for non-gateway callers.

Existing delegate-guard tests updated to pass full payloads — the
prior `title='x', description='y'` minimal stubs now hit the
completeness gate first; the full payloads still exercise the
auth/chain/cap guards downstream.

* feat(api/routes/tasks): POST /tasks uses foundation.task_completeness check

Replace the hand-rolled acceptance_criteria non-empty check in the
POST /tasks handler with a call to task_completeness.check(TASK_AT_CREATE,
data). Route, schema (TaskCreate), and service (TaskCreateRequest) now
all share one canonical notion of 'complete' — the fourth and final
create path is now strict.

Pydantic still rejects structurally invalid payloads (empty AC list,
short title/description, missing enums) with 422. The TC check at the
route boundary additionally rejects denylisted placeholder phrases
('completed and reviewed by assignee', etc.) that pass schema validation
but signal a stub task.

Add tests/integration/test_post_tasks_completeness.py:
  - empty acceptance_criteria  -> 422 (Pydantic)
  - placeholder phrase         -> 400/422 with 'acceptance_criteria' in body

* chore(make): add foundation-check drift gate (mirrors lifecycle-check)

* test(foundation): Phase 1 smoke gate — skeleton-task path returns incomplete_input

Phase 1 closes here: identity catalogs are single-sourced; the silent
acceptance_criteria fallback is gone; gateway delegate returns
incomplete_input with populated field_hints when criteria are missing.

The 2026-05-10 smoke run that produced skeleton tasks no longer can.
Phases 2-4 (tracing, journaling, communications, agent_loop, housekeeping)
get their own plans.

* feat(foundation/policy): journaling scope catalog (5 panel-UI scopes)

* feat(foundation/policy/journaling): role read tiers + protected journals

* refactor(content_actions): derive _VALID_NOTE_SCOPES from foundation.journaling

* refactor(services/journal): derive _SCOPE_TO_TYPE from foundation.journaling

* refactor(enforcement/journal_perms): import read-tier rules from foundation

PROTECTED_JOURNALS + ROLE_READ_TIERS now sourced from foundation.policy.journaling.
The local helpers (_check_protected_access, _check_cell_pm_access,
_check_cell_member_access) are collapsed into a single tier-driven check via
_decide_protected / _decide_by_tier. Pre-Phase-2 GLOBAL_READERS that lumped
CEO/auditor/PO/HoM/main_pm together is split into ReadTier.ALL (ceo+auditor —
includes protected) vs ReadTier.ALL_CELLS (others — excludes protected).
Observable behavior preserved.

* feat(foundation/policy): tracing Requirement enum + check_requirements

19 requirements (16 from pre-Phase-2 tracing_gate + 3 pre-gateway parity:
JOURNAL_NOTE_AT_CLAIM, JOURNAL_DECISION_AT_CLAIM, JOURNAL_DURING_WORK).
GateContext expanded with the new presence flags and journal_during_work_count.
Acceptance-criteria checker keeps the spec §9 item 1 reflect-note shortcut.

* feat(foundation/policy/tracing): VERB_REQUIREMENTS table + verb parity validator

Maps every gateway intent verb to its required-set. Includes the 6 inline
journal:decision callsites (submit_up, complete, unblock, escalate_up,
escalate_to_ceo, delegate) plus the 4 pre-gateway parity additions
(NOTE_AT_CLAIM, DECISION_AT_CLAIM, REFLECT on complete, DURING_WORK).
Validator asserts every spec verb is covered or explicitly waived, and
every Requirement enum value is used by at least one verb.

PLAN added to i_will_work_on / i_will_plan (mirrors spec.PRECONDITION_PLAN
in the tracing layer). SELF_VERIFIED added to i_am_done as a defense-in-depth
backstop (auto-set by the in_progress→verifying transition).

* refactor(gateway/i_am_done): tracing gates via foundation.policy.tracing

Adds JOURNAL_DURING_WORK_AT_LEAST_ONE check (pre-gateway parity P2 —
agents must write at least one decision/learning/struggle entry between
claim and submit). Adds journal.has_struggle_for_task helper.
Replaces the pre-Phase-2 tracing_gate.check_requirements call.

SELF_VERIFIED is filtered from the pre-flight required-set: the spec
composes (submit_verification, submit_qa) for i_am_done and the
auto-run submit_verification flips self_verified=True before submit_qa
runs. The flag therefore acts as a defense-in-depth backstop AFTER the
spec, not before — checking it pre-flight would block the auto-verify
path. SELF_VERIFIED stays in the foundation required-set and is
re-asserted by the spec action's own preconditions.

Test fixtures updated: 9 i_am_done success-path tests now mock
has_decision_for_task=True (or equivalent) so the new during-work
cadence gate is satisfied. NO_PR-token assertion broadened to also
accept the foundation token "pr_open".

* refactor(gateway/qa): pass/fail gates via foundation.policy.tracing

* refactor(gateway/doc): i_documented gates via foundation.policy.tracing

Doc-specific missing-key translations (docs_notes>=min, docs_files_non_empty)
added to the central _build_tracing_gap translator established in Task 9.

* refactor(gateway): unify 6 inline journal:decision checks via tracing.check_requirements

Pre-Phase-2 inline blocks at _impl.py lines ~2230/2394/2442/2574/2814/2895
each ran the same has_decision_for_task + Envelope.tracing_gap pattern. They
now call:

- _check_pm_decision_required(verb, ...) — for unblock, escalate_up,
  escalate_to_ceo, delegate. Each declares only JOURNAL_DECISION in
  VERB_REQUIREMENTS, so a single helper consuming
  tracing.requirements_for(verb) suffices.
- _check_complete_gates — for cell_pm_complete and main_pm_complete.
  Consumes VERB_REQUIREMENTS["complete"] = JOURNAL_DECISION + JOURNAL_REFLECT
  + NOTES_MIN_CHARS. The inline _subtasks_not_terminal_envelope is kept
  because its remediation enumerates the non-terminal subtask ids — strictly
  richer than the foundation hint.
- _check_submit_up_gates — for submit_up. Consumes
  VERB_REQUIREMENTS["submit_up"] minus SUBTASKS_TERMINAL (deferred to the
  inline envelope for the same reason as complete).

Also adds the journal:decision tracing gate to the delegate verb
(VERB_REQUIREMENTS["delegate"] = {JOURNAL_DECISION}) — pre-gateway PM.md
required journal:decision before each delegate, but the gateway path had
not yet enforced it. Threaded into _delegate_extra_guards so the verb
body's return count stays under the lint cap.

_build_tracing_gap gains hint translations for journal:decision, notes>=min,
and subtasks_terminal. The body is refactored to a static dispatch table
+ acceptance-criteria batch handler so the branch count stays under the
lint cap.

PM-verb success-path tests updated to provide notes >= 20 chars (the new
NOTES_MIN_CHARS gate); has_reflect_for_task mocks added to a few tests
where they're now load-bearing (AsyncMock truthiness covers most).
6575 tests passing, mypy + ruff clean.

* feat(gateway/claim): require journal:note_at_claim and journal:decision_at_claim

Pre-gateway parity P1, P3: developers wrote a note (scope='note') on
every claim; PMs wrote a decision (scope='decision') on plan. Restored
via foundation.policy.tracing requirements wired through a new
_post_claim_journal_gate helper that runs AFTER the composed
(claim, set_plan, start) sequence completes.

Failed checks return tracing_gap with a remediate hint that tells the
agent to journal then retry. The claim itself stays — the agent
journals and re-issues the verb (idempotent re-entry shortcuts back
to OK once the entry is present).

Adds journal.has_note_for_task helper paralleling
has_decision/reflect/learning/struggle. The PLAN requirement is
filtered out of the post-claim check because spec.PRECONDITION_PLAN
already enforced it before the runner ran — re-asserting at the
tracing layer would emit a misleading hint.

Two new tests verify the gate fires for missing note/decision; existing
success-path tests already mock the journal service via AsyncMock
(returning truthy) so no regressions.

* test(foundation): Phase 2 smoke gate + tracing_gate.py deleted

Phase 2 closes here:
- foundation/policy/journaling.py owns the 5-scope catalog + read tiers
- foundation/policy/tracing.py owns Requirement enum + VERB_REQUIREMENTS
- 6 inline journal:decision checks replaced with unified helpers
- pre-gateway parity restored: NOTE_AT_CLAIM, DECISION_AT_CLAIM,
  DURING_WORK, REFLECT-on-complete
- services/gateway/tracing_gate.py deleted
- enforcement/journal_perms.py read-tier rules canonicalized

Smoke gate 2 enforces: no inline has_decision_for_task remains; every
intent verb has a tracing decision; tracing_gate module is gone.

* fix(foundation/task_completeness): align hint strings with actual enum values

_HINT_NATURE listed 5 values (technical | bugfix | feature | refactor | docs)
but TaskNature only has 2 (TECHNICAL / NON_TECHNICAL). _HINT_ESTIMATED_COMPLEXITY
listed "critical" which Complexity doesn't have. _HINT_TEAM omitted FULLSTACK
(real, used) and didn't note that MARKETING is legacy seed-data. _HINT_TASK_TYPE
was already correct.

Hints now reflect the actual enums in roboco/models/base.py and
roboco/foundation/identity.py — agents reading the gateway's incomplete_input
remediate envelopes will no longer be told to send values the enums reject.

Tests using nature="feature" (DelegateRequest's nature is `str`, not the
enum, so it accepted the fake value silently) updated to nature="technical"
so they exercise a real enum value end-to-end.

* fix(orchestrator): remove dead "critical" complexity branches

Complexity enum has only LOW / MEDIUM / HIGH — no CRITICAL value.
The three "critical" branches in dispatch logic at lines ~3032 / 3331 /
5049 were dead code (the comparison can never be true). Removed.

Surfaced during Phase 2 closeout when the foundation hint string was
audited against the actual enum.

* feat(foundation/policy/communications): Priority + NOTIFY_SENDER_ROLES + ACK_REQUIRED_BY_TYPE

* feat(foundation/policy/communications): CHANNELS catalog (channel topology)

* refactor(agents_config): derive CHANNEL_ACCESS from foundation.communications

* refactor(seeds): derive DEFAULT_CHANNELS / CHANNEL_MEMBERSHIPS from foundation

* refactor(content_actions): derive notify allowlist + priorities from foundation

Replaces _NOTIFY_ALLOWED_ROLES + _VALID_NOTIFY_PRIORITIES literals with
derivations from foundation.communications.NOTIFY_SENDER_ROLES + Priority.

Behavior change: pre-Phase-3 the literal frozenset {cell_pm, main_pm,
product_owner, head_marketing} excluded CEO. Foundation includes CEO
(per spec 5.5). The contradiction with agents_config.NOTIFICATION_PERMISSIONS
(which already granted CEO can_send=True) is now resolved.

* refactor(notification_delivery): requires_ack from foundation.ACK_REQUIRED_BY_TYPE

* refactor(enforcement,agents_config): delete dead notification policy

- enforcement/notification_perms.py deleted (dead at call-graph; only
  the enforcement/__init__.py re-export kept it reachable, and that
  re-export is gone too).
- agents_config.NOTIFICATION_PERMISSIONS dict deleted; agents_config
  .can_send_notifications now derives from
  foundation.policy.communications.NOTIFY_SENDER_ROLES (auditor
  correctly excluded — silent observer per spec §5.5).
- services/permissions.py: _can_role_send_notifications and
  can_agent_send_notifications now derive from NOTIFY_SENDER_ROLES;
  _get_notification_scope encodes the scope rule (cell/all/list)
  locally as a function-of-role and returns list[AgentRole] instead
  of list[slug]; can_notify list-scope branch updated to match.
- enforcement/__init__.py: removed the notification_perms re-export
  and the NotificationPermissionError, get_notification_scope,
  validate_notification_permission names from __all__.

Closes the spec §3 contradiction: gateway content_actions
._NOTIFY_ALLOWED_ROLES (Task 5) and the legacy
agents_config.NOTIFICATION_PERMISSIONS no longer disagree about
whether auditor may call notify(). Both now derive from
foundation.NOTIFY_SENDER_ROLES.

* fix(content_actions): runtime auditor guard in say/dm (defense in depth)

Closes the spec §5.5 gap where the auditor's silent role was enforced
ONLY by manifest exclusion. The manifest pre-filters the tool surface
exposed to the auditor agent, but if anything bypassed it, the auditor
could speak. The new runtime guard in ContentActions.say/dm refuses
with Envelope.not_authorized when the caller's role is "auditor",
regardless of how the call arrived.

* fix(a2a): pass Priority tristate end-to-end (was reduced to boolean)

Pre-Phase-3 path:
  request priority: str -> services/a2a.py reduces to urgent: bool
  -> services/notification.py maps bool back to NotificationPriority
This made Priority.HIGH unreachable through the A2A path.

After this fix the full tristate (NORMAL/HIGH/URGENT) survives end-to-end:

  * services/a2a.py:create_a2a_notification parses metadata["priority"]
    (preferred) or falls back to legacy metadata["urgent"] / config.urgent
    (URGENT-only). Unknown values fall back to NORMAL.
  * services/notification.py:send_a2a_notification now takes
    a2a_context["priority"] (NotificationPriority); a defensive bool/str
    coerce keeps legacy callers from crashing.
  * runtime/orchestrator.py:_build_a2a_prompt reads priority off the
    notification row (the source of truth) instead of a non-existent
    metadata.urgent and renders three tiers: URGENT bold, HIGH softer,
    NORMAL no prefix.

Cosmetic [URGENT] body/subject prefix stays urgent-only; HIGH gets no
prefix but is recorded as HIGH at the NotificationTable.priority column.

Tests:
  * 9 new tests in tests/integration/test_a2a_priority_tristate.py
    pinning the round-trip for HIGH/NORMAL/URGENT through both layers
    plus legacy-bool backcompat.
  * Updated tests/unit/services/test_notification.py::test_send_a2a_notification
    to the new priority= contract.

Closes the spec section 3 contradiction flagged in the audit.

* feat(foundation/policy): agent_loop BudgetPolicy + VERB_RETRY_LIMITS

* refactor(agent_sdk): import budget thresholds from foundation

* refactor(orchestrator): import _PM_RESPAWN_MAX_UNPRODUCTIVE from foundation

* fix(post-tool-budget-hook): exit 1 on loop-halt (was exit 0 / non-blocking)

Pre-Phase-3 the hook printed [Loop] and exit 0'd — agents could ignore it
and keep retrying. The 2026-05-10 smoke run showed i_am_done retried 5+
times within the global 150-tool budget, never hitting a real wall.

Now the hook reads the SDK response's loop_action field (sourced from
foundation.BudgetPolicy.loop_action; default "halt") and exits 1 to
deny the wrapping tool call when the rolling-window loop detector fires
AND loop_action is "halt". Operators can soften via env
ROBOCO_AGENT_LOOP_ACTION=warn for debugging.

Changes:
- BudgetStatus pydantic model: add loop_action: Literal["warn", "halt"]
  (default "halt") so the SDK response carries the policy.
- agent_sdk/server.py: read ROBOCO_AGENT_LOOP_ACTION env override on top
  of foundation default and surface it in _budget_snapshot().
- post-tool-budget-hook.sh: parse .loop_action, exit 1 to stderr when
  loop+halt; falls back to legacy warn-only print if the field is
  missing (older SDK / partial deploy).

* feat(agent_sdk): per-verb retry circuit breaker via foundation.VERB_RETRY_LIMITS

Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke
showed i_am_done retried 5+ times in 2 minutes within the global 150-tool
budget — the agent never hit a real wall.

Now the SDK tracks (verb, task_id) -> deque[timestamp] over a 60s sliding
window. When the count for a verb exceeds foundation.retry_limit_for(verb),
the next attempt receives Envelope.circuit_open with a remediate hint
pointing to i_am_blocked / i_am_idle as graceful exits.

Verbs in foundation.UNLIMITED_RETRY_VERBS (give_me_work, triage,
evidence, etc.) bypass the breaker. Only rejection envelopes
(tracing_gap, invalid_state, not_authorized, incomplete_input) feed
the counter — successful calls do not count.

Wire-up:
- Envelope.circuit_open classmethod + as_dict pass-through
- _SessionState.verb_attempts: defaultdict[(verb, task_id), deque[float]]
- Helpers _record_verb_attempt / _verb_attempt_count / _check_verb_circuit
- POST /verb/attempted: hook posts after a rejected gateway call;
  response carries breaker state + (when open) the wire-format
  Envelope.circuit_open dict the agent should surface to itself
- GET /verb/circuit_status: read-only state probe
- _state.reset() (also POST /budget/reset) wipes the tracker on spawn

* test(foundation): Phase 3 smoke gate + foundation-check extended

Phase 3 closes here:
- foundation/policy/communications.py owns Priority, NOTIFY_SENDER_ROLES,
  ACK_REQUIRED_BY_TYPE, ChannelSpec, CHANNELS, parse_priority
- foundation/policy/agent_loop.py owns BudgetPolicy, VERB_RETRY_LIMITS,
  UNLIMITED_RETRY_VERBS, retry_limit_for
- 6 channel topology fork sites collapsed to one source (CHANNELS)
- Notification sender contradiction closed (CEO included; auditor excluded)
- A2A urgency tristate restored (HIGH reachable end-to-end); A2A
  service now consumes parse_priority instead of inlining branches
  (also drops create_a2a_notification CC from C/13 to A/<10)
- Auditor silent role enforced at runtime in say/dm
- enforcement/notification_perms.py deleted (was dead code)
- 7 hand-set requires_ack callsites consolidated to ACK_REQUIRED_BY_TYPE
- post-tool-budget-hook.sh exits 1 on loop-halt
- Per-verb retry circuit breaker live in agent_sdk (60s sliding window)

make foundation-check now validates communications + tracing + journaling
+ identity drift in one command. make quality green.

* refactor(lifecycle): copy spec.py to foundation/policy/lifecycle.py + shim

Phase 4 Task 1 — relocates the canonical lifecycle spec next to its policy
siblings (task_completeness, tracing, journaling, communications, agent_loop).

The original roboco/lifecycle/spec.py is now an explicit re-export shim;
consumers continue to work unchanged. Subsequent Phase 4 tasks (2-7) migrate
the imports in batches, then Task 8 deletes the shim.

No behavior change — pure code move.

* refactor(services): import lifecycle from foundation (Phase 4 batch)

* refactor(agents,enforcement): import lifecycle from foundation (Phase 4 batch)

* refactor(tests): import lifecycle from foundation (Phase 4 batch)

* refactor(foundation): absorb lifecycle _validate + _generators

Phase 4 Tasks 9 + 10. Moves the lifecycle spec's internal validators
to roboco/foundation/_validate_lifecycle.py and its RAG/prompt artifact
emitter to roboco/foundation/_generators.py.

The lifecycle validators live in a sibling module (not merged with
foundation/_validate.py) because the lifecycle spec imports from
foundation at module load — placing the lifecycle checks alongside the
identity checks would create an import cycle between
roboco.foundation and roboco.foundation.policy.lifecycle (the latter
calls the validators at the bottom of its own definition). The
_validate_lifecycle module defers its policy.lifecycle imports to
function bodies so it loads cleanly when the spec hasn't finished
initialising yet; the per-file PLC0415 exemption in pyproject.toml
documents the reason.

Test files relocated:
- tests/lifecycle/test_spec.py        -> tests/foundation/test_lifecycle_spec.py
- tests/lifecycle/test_generators.py  -> tests/foundation/test_lifecycle_generators.py

scripts/build_lifecycle_artifacts.py now imports the generators from
roboco.foundation; the on-disk artifacts (docs/rag/lifecycle,
panel/lib/lifecycle.json, agents/prompts/_generated/lifecycle-*.md)
regenerate byte-identically.

After this commit, roboco/lifecycle/ contains only the spec.py and
__init__.py re-export shims — Task 8 deletes those.

No behavior change. 6638 tests pass; make quality green.

* refactor(lifecycle): delete legacy roboco/lifecycle/ package

Phase 4 Task 8. All consumers migrated to roboco.foundation.policy.lifecycle
in Tasks 2-7; the internal validators + generators moved to foundation in
Tasks 9-10. The legacy package contained only re-export shims.

Also trims tests/foundation/test_role_reexport.py — the two assertions that
checked the lifecycle.spec shim's object-identity are gone with the shim.
The two models.base shim assertions (AgentRole / Team) are still
meaningful and stay.

Inline docstrings / comments in enforcement/task_lifecycle.py,
services/gateway/role_config.py, services/gateway/content_actions.py,
tests/integration/test_task_service_lifecycle_misc.py and
foundation/policy/lifecycle.py that referenced the now-deleted
roboco.lifecycle.spec module are updated to point at
roboco.foundation.policy.lifecycle.

After this commit, roboco.lifecycle is gone. Lifecycle policy lives only
at roboco.foundation.policy.lifecycle. Adding new lifecycle rules edits
exactly that one file.

* refactor(api): consolidate route-guard role-sets via foundation

Replace hand-written role-name string frozensets in roboco/api/deps.py
(_PM_OR_ABOVE_ROLES, _DEVELOPER_OR_ABOVE_ROLES, _GLOBAL_CELL_ACCESS_ROLES)
and roboco/api/routes/v2/_role_dep.py (require_dev/qa/doc/cell_pm/main_pm/
board/auditor) with foundation-derived expressions over PM_ROLES,
BOARD_ROLES, DEV_ROLES, and Role enum members.

Behavior is preserved: Role is a StrEnum, so the lowercase X-Agent-Role
header still compares equal to its matching member. HEAD_MARKETING stays
excluded from every -or-above set (marketing spokesperson, not approver);
the carve-out is now expressed as (BOARD_ROLES - {Role.HEAD_MARKETING})
instead of an opaque literal.

Adds tests/foundation/test_route_guard_consolidation.py (6 tests) pinning
both the foundation-derived membership and the import contract.

* test(foundation): Phase 4 smoke gate + housekeeping closeout

Phase 4 closes the foundation canonicalization effort (Phases 1-4 spanning
2026-05-10 -> 2026-05-11):

Phase 1 - identity + task_completeness (skeleton-task bug killed)
Phase 2 - tracing + journaling (pre-gateway cadence restored)
Phase 3 - communications + agent_loop (channel/notification/A2A/circuit-breaker)
Phase 4 - housekeeping (lifecycle moved to foundation; consumers migrated)

All cross-cutting policy now lives in roboco/foundation/. Adding a policy
edits exactly one file. The legacy roboco.lifecycle package is gone.
Smoke gates 1-4 enforce: no skeleton tasks, no inline journal:decision
checks, channel topology canonical, A2A tristate preserved, auditor silent
at runtime, lifecycle module path canonical.

make quality + make foundation-check both green.

* fix(mcp/agent_sdk): wire per-verb circuit breaker into response handler

Phase 3 Task 14 added the SDK infrastructure (tracker, endpoints,
Envelope.circuit_open, retry_limit_for) but nothing was actually
recording rejections — the breaker never tripped. This commit wires
the gateway-response path so every rejection envelope (tracing_gap /
invalid_state / not_authorized / incomplete_input) hits
POST /verb/attempted, and if the breaker is open, the envelope is
substituted with the circuit_open response before the agent sees it.

Best-effort: SDK-unreachable / malformed-response failures fall open
(agent sees the original rejection), so the breaker never breaks the
gateway path.

* fix(notification_delivery): retype CEO approval-flow notifications APPROVAL

notify_assignee_of_ceo_rejection and notify_ceo_of_escalation were both
typed NotificationType.TASK_ASSIGNMENT, which the Phase 3 foundation
table (ACK_REQUIRED_BY_TYPE in roboco/foundation/policy/communications.py)
maps to requires_ack=False. Both are approval-flow notifications and
should mandate acknowledgment.

Retyped both to NotificationType.APPROVAL so the table lookup yields
requires_ack=True via ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL].

* test(foundation): move lifecycle parity + smoke-replay tests under tests/foundation/

Phase 4 Task 8 deleted roboco/lifecycle/ but tests/lifecycle/ still held
two files importing roboco.foundation.policy.lifecycle. Mirror the layout
of test_lifecycle_spec.py and test_lifecycle_generators.py (moved in
Phase 4 Tasks 9+10) by relocating them under tests/foundation/ with the
test_lifecycle_* prefix, then delete the now-empty tests/lifecycle/
package.

  tests/lifecycle/test_consumer_parity.py
    -> tests/foundation/test_lifecycle_consumer_parity.py
  tests/lifecycle/test_smoke_replay.py
    -> tests/foundation/test_lifecycle_smoke_replay.py

* build(make): consolidate ci-lifecycle-check into foundation-check

ci-lifecycle-check was a thin wrapper that regenerated lifecycle artifacts
via scripts/build_lifecycle_artifacts.py and gated on git diff. After
Phase 4 it sat alongside foundation-check covering the same drift-gate
intent. Merge the lifecycle-artifact regen + git-diff step into
foundation-check so a single 'make foundation-check' is the canonical
drift gate.

Keep ci-lifecycle-check as a phony alias forwarding to foundation-check
for any external script or CI lane still using the old target name.
Drop the redundant ci-lifecycle-check call from 'make quality'.

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-05-11 02:15:47 +02:00