Commit Graph
148 Commits
Author SHA1 Message Date
Renn F 7f5967bc02 [F129,F130] harden quality gate _run_one exit status + timeout cleanup
F129: _run_one returned 'proc.returncode or 0', masking a None returncode
(communicate returned without a recorded exit code — process killed
out-of-band) as 0 / success. Treat None as a non-zero failure (fail-closed).

F130: on timeout, _run_one killed the subprocess but never awaited wait()
— communicate() was cancelled so it never closed the stdout/stderr pipes,
leaving a transient zombie + leaked FDs. Await wait() after kill() to reap
the process and close the transports.
2026-06-29 00:21:44 +02:00
Renn F f69d13a1d1 [F128] require active claim on explicit-task content posts
_verify_explicit_task_ownership checked assigned_to, which is stale
across a reap/handoff (persists until reassignment; active_claimant_id is
cleared on release). A reaped agent could keep posting say/dm/note to its
former task. Add the active-claimant check when assigned_to == caller;
assigned_to=None keep its existing allow (read-side inspection between
reassignments uses evidence, which has its own ownership path).

Existing 'active owner' test mocks passed assigned_to=agent_id without
active_claimant_id; production sets both together on claim, so the mocks
were incomplete. Updated to set both — realistic, not a behavior change.
2026-06-29 00:15:13 +02:00
Renn F a3d1769018 [F127] per-task advisory lock prevents open_pr milestone double-emit
open_pr's idempotent re-entry guard (pr_number is not None) read t.pr_number
from an unlocked fetch. Two concurrent same-task open_pr calls (the
alive-but-unresponsive respawn race) both fetched pr_number=None, both passed
the guard, both ran the runner (GitHub 422 ensures one PR), and both reached
_record_milestone_progress -> a double-emitted 70% 'opened PR #N' entry.

Fix: acquire_task_lock (pg_advisory_xact_lock, seed 2) before the fetch, held
through the runner + milestone + request commit. The second concurrent call
blocks until the first commits, then its fetch sees the committed pr_number
and the idempotent guard short-circuits without re-emitting. Per-task (single-
active-task guard means same-task concurrent open_pr is only the bug case).
2026-06-29 00:05:24 +02:00
Renn F 145b2828ba [F125] serialize same-parent delegate via per-parent advisory lock
The delegate sibling-dedup guard read the parent's existing subtasks via an
unlocked get_subtasks SELECT (the dedup read) then created the subtask (the
write) with no DB serialization between them. Two concurrent delegate calls
for the same parent (PM re-delegating while a reaper re-dispatches, or two
orchestrator ticks racing) each read a duplicate-free sibling set, each passed
the dedup guard, and each created a subtask — the parent got the duplicate the
guard exists to prevent (the smoke-run runaway pattern).

Fix: a PostgreSQL transaction-scoped advisory lock keyed by the parent task
id (seed 1, disjoint from the per-agent claim lock's seed 0), acquired at the
top of the delegate body before the first get_subtasks read (the briefing
context read AND the dedup sibling read) and held through create_subtask's
flush + the outer request commit. The second concurrent same-parent delegate
blocks until the first commits, then its dedup read sees the committed
sibling and is rejected.

Per-PARENT (not per-agent): a coordinator PM legitimately delegates many
subtasks under one parent in quick succession and plans many roots in
parallel — a per-agent lock would serialize all of a PM's delegates and
regress the PM coordinator concurrency feature. The per-parent lock
serializes only same-parent delegates (the dedup invariant is per-parent)
and leaves different parents untouched.

TDD: red-first ordering test (lock acquired before first get_subtasks read
and before create_subtask) + no-regression test (create still runs).
2026-06-28 23:58:00 +02:00
Renn F 6f31016387 [F124] re-check dependency state before releasing a dependency-blocked claim
The unmet_dependency guard read dependency state via an unlocked SELECT, then
fired release_dependency_blocked_claim (a state mutation: claimed/in_progress
-> pending, clears branch_name, abandons WorkSession) as a side-effect BEFORE
returning the rejection. An upstream dependency that reached a terminal state
(completed/cancelled) in the microseconds between the read and the release left
the task NEEDLESSLY released — its branch cleared + WorkSession abandoned +
assignee bounced, only to be re-dispatched + re-claimed when the dependency-
completion re-dispatch fired a moment later.

Re-check unmet_dependency_ids immediately before the release and skip it
(returning None — proceed) when the upstream just completed. Dependencies are
monotonic (unmet -> met only; terminal states never reopen), so a fresh read
that now finds them met stays met: safe to proceed without releasing. The
'still unmet' path is byte-for-byte the prior behavior (no regression). The
cross-task residual window (upstream completes between the re-check and the
release) is not closable by a row lock on the dependent, but the re-check
narrows the window from [first read -> release] to [re-check -> release], and
in the common case the first read already sees met (no guard fires). No
committed-work loss either way (a dependency-blocked task has none; the branch
ref + commits persist across the branch_name clear).
2026-06-28 23:52:03 +02:00
Renn F c26b6a59d0 [F122] name the already-open PR in submit_up's None-state remediate
submit_up's create_pr pre-side-effect opens the cell→root PR BEFORE
submit_for_review runs (its pr_created gate requires it — lifecycle.py:1338-1343).
When submit_for_review returns None (a concurrent state change raced the task
out of in_progress between the precondition gate and the composed action), the
old remediate ('check task state — must be in_progress with PR ready') hid
that the PR was already open on GitHub — an orphaned external artifact the PM
could not reconcile. Mirror submit_root's F016 None-envelope remediate: name
the open PR, point the PM at re-fetch + reconcile, and note create_pr is
idempotent so a re-issue re-attaches to the existing PR (no duplicate). Pure
message improvement — zero behavior change; reordering is off the table
(create_pr must precede the pr_created gate).
2026-06-28 23:34:49 +02:00
Renn F f779fe7453 [F109] playbook curation status guards: approve/reject draft-only, archive approved-only 2026-06-28 22:21:32 +02:00
Renn F f4c67bedff [F099] wire pr_pass/pr_fail self_review block in the spec gate
The pr_pass/pr_fail ActionSpecs carry self_review_block=True, but
_gate_preflight never populated Context.original_developer_slug, and
actor_slug was read off agent.slug — which GatewayAgentView does not
carry, so it was always None in production. The block was structurally
dormant: a reviewer who was also the original developer of the
assembled PR could pass (or fail) their own work. The service-layer
_validate_not_self_review backstop only covers qa/documenter, not
pr_reviewer, so the spec gate is the only defense.

Set actor_slug=str(reviewer_agent_id) (GatewayAgentView has no slug,
so the UUID is the identity) and original_developer_slug from the
original_developer marker (a UUID stored as a string). Both resolve to
UUID strings, so the spec's string-equality comparison fires when the
reviewer IS the recorded original developer.

The marker is never set on assembled coordination tasks (only on
dev-leaf tasks at QA/doc claim), so the block stays dormant by design
in production — but the gate is now correctly wired to fire if the
marker were ever set to the reviewer. Zero production behavior change;
the dormant-in-production state is pinned by the no-marker test.
2026-06-28 21:10:52 +02:00
Renn F 02984b996f [F074] per-agent advisory lock closes claim TOCTOU
_run_claim_guards read the agent's other tasks via unlocked SELECTs
before claim() took its row lock, and claim()'s FOR UPDATE locked only
the TARGET row — so two concurrent i_will_work_on by the SAME agent on
TWO DIFFERENT pending tasks each locked their own row, each read an
empty in_progress set, each passed already_active, each claimed+started
→ the agent ended with two in_progress tasks (the in-process asyncio
Lock is lost on orchestrator-restart split-brain, so it wasn't a
DB-level guarantee).

Fix: TaskService.acquire_claim_lock takes a transaction-scoped
pg_advisory_xact_lock keyed by hashtextextended(agent_id). The gate
acquires it BEFORE the guard reads (for non-coordinator roles only) so
the second concurrent claim's read sees the first's committed
in_progress task and is rejected. Tx-scoped → auto-releases on
commit/rollback, can't outlive the request.

Coordinator exemption (the key logical-regression guard): cell_pm /
main_pm do NOT take the lock — the PM coordinator concurrency feature
lets a PM plan+delegate many roots in parallel, and a per-agent lock
would serialize those claims and regress it. Matches the existing
_COORDINATOR_ROLES already_active/paused guard exemption. A hash
collision only causes benign false serialization, never a false
negative.

Tests: unit (dev acquires lock before guard read; coordinator does
not) + real-PG integration (same-agent serializes, different-agent
does not, releases on rollback).
2026-06-28 18:15:56 +02:00
Renn F 55cc7cea4e [F076] say/dm: handler guard rejects all 4 no-comms roles, not just auditor
The say()/dm() defence-in-depth guard only rejected auditor, but CLAUDE.md
mandates the same no-agent-comms invariant for pr_reviewer (posts findings
on the PR), prompter and secretary (human-only, note + evidence). For those
three the manifest was the only gate, so a call bypassing the manifest
(direct API POST, test harness, future routing change) would not be refused
at the handler — admission depended on the agent's slug happening to be
absent from the channel/a2a matrix. Extend the guard to a _NO_COMMS_ROLES
frozenset (auditor + pr_reviewer + prompter + secretary), matching the
explicit role-frozenset gates on commit/notify/pitch/playbook/open_session.
Role-appropriate remediation per role. The claimed defence-in-depth now
covers 4 of 4 silent roles, not 1 of 4.
2026-06-28 18:02:08 +02:00
Renn F f826285651 [chore] remove all remaining type:ignore suppressions from tests/
Converts 115 `# type: ignore[...]` suppressions across 23 test files to
no-suppression patterns (helper-return widening to Any, local Any aliases,
cc:Any aliases, cast at narrow call sites, typed fixtures) so the hard
no-type:ignore convention holds across tests/. No test logic or assertions
changed — only mock-wiring mechanics and type annotations.

Gate: ruff check tests/ clean; mypy tests/ (538 files) clean; 176 changed-file
tests pass. Zero real suppressions remain (the 7 grep hits are 3 hygiene-
checker string-literal test inputs and 4 prose mentions in comments).
2026-06-28 17:00:20 +02:00
Renn F 4d4bf084c5 [chore] clear all 64 pre-existing mypy errors in tests/ (no type:ignore)
Convention: no type:ignore/noqa, and pre-existing violations still
violate. The make-quality gate runs 'mypy roboco/ tests/', but the
prior commits' gates only ran mypy on production files, masking 64
type errors across 15 test files (method-assign, unused-ignore,
no-untyped-def, attr-defined, union-attr, has-type, index, misc).

Fixed without any type:ignore:
- method-assign (svc.session.X = / svc.method = AsyncMock()): hold a
  local 'session: MagicMock'/'AsyncMock' and assert on it, or stub via
  object.__setattr__ / monkeypatch / a typed '_bind' helper returning
  Any, or alias 'cc: Any = c' (the pattern the file already used).
- unused 'type: ignore[assignment]' (real code was method-assign):
  removed; replaced with the no-suppression patterns above.
- 'Callable[...] has no attribute assert_*': keep a typed local ref to
  the AsyncMock and assert on the local, not the method-typed attr.
- no-untyped-def: annotate helper params (Any / pytest.MonkeyPatch).
- attr-defined / index / union-attr: type the helper as Any, narrow
  with an 'is not None' assert, or add the missing attr to a fake.
- has-type / return-value: fix the declared return type to the tuple
  the function actually returns.
- PLC0415 inline imports: hoisted to top-level.

test_pr_gate_notifies_pm._stub_gate_path converted fully to the
'cc: Any = c' alias (it already used it for one attr) so its five
'# type: ignore[method-assign]' suppressions are gone.

mypy tests/: 64 errors -> 0 (538 files). ruff check tests/: clean.
All 84 tests in the touched files pass.
2026-06-28 16:47:09 +02:00
Renn F bffb0b7200 [F057] playbook: index/unindex as a post-commit step so the RAG corpus never leads the status transaction 2026-06-28 16:08:45 +02:00
Renn F 67b8893071 [F048] notify: reject human-only recipients (prompter/secretary) — no agent ack path
notify() only checked the SENDER role. The recipient was resolved by
NotificationService._resolve_recipients, which drops only unresolvable slugs
— it does not exclude human-only roles. The prompter (intake-1) and secretary
(secretary-1) are seeded agent rows, so they resolved, and an ack-required
ALERT addressed to them sat permanently unacked (no agent auto-acks it),
polluted the panel's pending-ack view, and — via the dedup query's
~acked_by.contains — permanently suppressed any later same-purpose
notification from the same sender to that human role. The knowledge-share
path already excludes all three human-only roles; the general notify path
did not.

Fix: a recipient-role guard in notify() via _reject_disallowed_recipient
(folds the new check into the existing CEO-dependency-block return slot so
notify stays under the PLR0911 return limit). Rejects prompter/secretary
with not_authorized; the CEO is human too but acks via the panel, so it stays
an allowed recipient (its only disallowed case, a dependency-block page, is
preserved). TDD test_notify.py (+3: reject prompter, reject secretary, allow
CEO).
2026-06-28 15:38:56 +02:00
Renn F ee5321fde1 [F047] conventions: reviewer-aware block-finding remediation on pr_pass gate
The pr_pass (reviewer) conventions guard reused the dev-path block-finding
remediation: 'add a waiver to .roboco/conventions.yml in your branch'. A
pr_reviewer does not own the assembled cell->root / root->master branch and
has no commit verb on it, so the waiver remediation is unreachable — a false
positive stranded the gate with no self-recovery (the reviewer could neither
commit a waiver nor pr_pass). The fail-open content path is documented
precision-over-recall and stays as-is; the actionable gap is the remediation.

Fix: _conventions_rejection now branches the block-finding remediation on
reviewer=True (mirroring the could_not_run branch from F044). The reviewer
path points at pr_fail carrying the findings as issues so the PR returns to
needs_revision and the DEV fixes the violation or commits the waiver (the dev
CAN commit to the branch); waiver authorship is framed as the dev's action,
not the reviewer's. Dev i_am_done path wording unchanged. TDD
test_conventions_gate_pr_pass.py (+1).
2026-06-28 15:35:59 +02:00
Renn F 86ab732f7a [F046] pr_gate: guard None runner result on concurrent transition (pr_pass/pr_fail)
_gate_decision dereferenced the verb-runner result without a None guard.
run_intent returns None when a concurrent transition (cancel or a racing
reviewer) moves the task out of awaiting_pr_review between the precondition
gate and the runner's final composed action (the verb runner's documented
last-action source-status contract). The subsequent t.assigned_to /
t.status / _post_gate_review_to_pr(t, ...) dereferences then crashed the
gate with a 500 AttributeError. Add a None guard that surfaces a clean
invalid_state rejection (re-fetch + re-issue) before any dereference; no
PR post or a2a runs against a None task. TDD test_pr_gate_notifies_pm.py (+2).
2026-06-28 11:38:40 +02:00
Renn F a6de1330aa [F045] rate-limit: loud activate-failure log + in-memory orphan-probe fallback
The in-verb i_am_blocked(rate_limited) path wrapped RateLimitStateTracker.activate
in a bare contextlib.suppress. A silent activate failure stranded the fleet:
agents were parked in _waiting_records but the provider never entered the tracker,
so the tracker-driven _sweep_rate_limit_probes never probed it and no
_on_probe_success ever resumed them — parked agents stuck in WAITING_LONG.

Fix: (1) replace the bare suppress with a try/except that logs an error event
naming the provider + affected agents; (2) in _sweep_rate_limit_probes, after
probing the tracker-listed set, scan _waiting_records for any rate_limit_lifted
provider the loop did NOT cover and probe it via the time-expiry fallback (empty
state -> probe now) so _on_probe_success resumes the parked agents. The fallback
reads only local memory, so it still resumes when Redis was down at park time
(list_rate_limited_providers failure now falls through to the orphan scan instead
of returning early).
2026-06-28 11:36:47 +02:00
Renn F 05a5852bb5 [F044] pr_pass gate remediation points the reviewer at pr_fail, not i_am_blocked
The pr_pass gate runs the toolchain + conventions guards on the REVIEWER's
workspace, but their remediation text said 'call i_am_blocked' — a verb the
PR reviewer does not have. The reviewer would chase a verb they cannot call
instead of rejecting the PR.

Make the guards reviewer-aware: a reviewer=True flag (passed by _pr_pass_blocked)
switches the remediation to pr_fail(issues=[...]) — the reviewer's reject
lever, sending the PR back to needs_revision for the dev to fix the
environment / validator. The dev (i_am_done) path keeps i_am_blocked, which a
dev does have. _conventions_guard (the pr_pass path) now passes reviewer=True
through to _conventions_rejection.
2026-06-28 11:30:55 +02:00
Renn F 33dce5f127 [F018] claim_guards: treat blocked as active + broaden the guard lookup so a blocked dev can't double-claim 2026-06-28 10:48:26 +02:00
Renn F 366800299b [F008] evidence_builder: surface persisted pr_review verdict+issues in the PM task_handoff
The pr_fail a2a steer to the owning PM is fire-and-forget; a PM respawned
into needs_revision later read none of it (build_task_handoff never looked
at notes_structured), saw a generic 'needs revision' with zero concrete
change-requests, and re-submitted the same PR (the 2026-06-27 infinite
pr_fail loop on 9980d0a0 / PR #138). The signal-gap was only partially
closed by the a2a.

build_task_handoff now extracts notes_structured.pr_review
(verdict/summary/issues/head_sha — the slot pr_fail authors on every fail)
into a pr_review field on the handoff, so every PM briefing for the task
carries the concrete change-requests. A prior pr_fail alone now counts as
prior-work-worth-resuming. Type-guarded + capped; absent => no key (no
misleading empty slot).

TDD red->green; ruff + mypy clean; evidence_builder suite green (14 passed).
2026-06-28 10:25:53 +02:00
Renn F 2ed7f5978f [F007] choreographer: cell-level unchanged-PR re-submit loop-stopper for submit_up
The root loop-stopper (F016) was root-only; a weak cell PM could re-submit
the unchanged cell->root PR after a pr_fail and loop awaiting_pr_review ->
pr_fail forever (the cell analogue of the 2026-06-27 root loop).

pr_fail stamps the assembled PR's head SHA into notes_structured.pr_review
.head_sha for cell AND root gate tasks alike (the capture is gate-verb-
level, not root-level), so the same structural refusal applies to submit_up:
if the cell PR's current head SHA equals the SHA the last pr_fail recorded,
no new dev work landed on the cell branch -> the diff is byte-identical ->
refuse, do not re-open the gate. Different SHA -> branch advanced -> allow.

- _submit_up_unchanged_pr_guard mirrors _submit_root_unchanged_pr_guard
  (cell-PM remediation: re-delegate to the dev + wait for re-assembly),
  wired into submit_up after _submit_up_guard passes.
- Renamed shared _current_root_pr_head_sha -> _current_pr_head_sha (both
  guards use it; the lookup was never root-specific).
- Every ambiguous case FAILS OPEN (no prior fail, no recorded sha, no
  pr_number, no resolvable project, git/closed-PR None) — only the exact-
  unchanged case is hard-blocked.

TDD red->green; ruff + mypy clean; F007+F016 guard suites green (15 passed).
2026-06-28 10:23:54 +02:00
Renn F 742e0ebfaf [F016,F017] choreographer: surface invalid_state instead of None.status 500 on submit_root / i_am_blocked
Both verbs compose a single atomic action whose None return (the verb's
own result) flowed out of run_intent and was dereferenced as t.status,
HTTP 500-ing with no actionable rejection:

- F016 submit_root: submit_for_review returns None when the root->master
  PR was already opened / the task raced out of in_progress. Post-runner
  None-guard extracted into _submit_root_finalize -> invalid_state
  (re-fetch; if awaiting_pr_review the PR is open, wait for reviewer;
  else re-delegate fixes and retry) instead of None.status.

- F017 i_am_blocked: escalate returns None in four cases (no task, no
  agent, no resolvable escalation-target slug, no target agent row) e.g.
  a developer whose role has no PM above it. _run_i_am_blocked_intent
  now guards updated is None -> (t, invalid_state rejection) with
  remediation (re-fetch + escalate to CEO directly / retry) instead of
  the caller deref'ing None.status -> 500 + respawn-loop.

TDD red->green; ruff + mypy clean; gateway suite green (58 passed).
2026-06-28 10:20:39 +02:00
Renn F d579d3120a [F002] retype board-routed MegaTask root-subtasks code->planning on activation
_activate_batch_root_subtasks flipped a held root-subtask to team=MAIN_PM
but left task_type=code (intake only coerces main_pm-team drafts, so a
board-routed code root reached activation still code-typed). The
main_pm+code combo re-introduces the 2026-06-27 meltdown. Mirror
approve_and_start's own retype via main_pm_cannot_own_code so the
activated child is a planning-typed coordination root.

TDD: RED test_activate_batch_root_subtasks_retypes_code_to_planning
watched fail (task_type stayed CODE), then GREEN after the retype.
ruff+mypy clean; 125 batch/umbrella/approve tests green, no regressions.
2026-06-28 09:57:08 +02:00
Renn F fbddef403b [feature] i_am_done behind-base submit gate (Phase B2)
A sibling's PR merging into the parent branch while a dev worked leaves the
dev's branch behind its base — the assembled PR then can't merge cleanly and
the sibling's changes go missing (the 2026-06-27 out-of-order dev-task break).
The behind-base gate refuses i_am_done in that state and steers the dev to
sync_branch (the Phase B1 gate-level rebase verb).

- GitService.is_behind_base: rev-list --left-right --count across
  origin/{base}...origin/{head} → (behind, ahead); fetch-first so origin
  reflects the pushed head. Raises on git failure (consistent with
  rebase_onto_base); malformed stdout degrades to (0,0).
- Choreographer._behind_base_gate: wired into _i_am_done_gate after
  _ensure_branch_pushed. behind>0 → invalid_state remediate→sync_branch.
  Fail-open on git/base-resolution error (flaky fetch can't strand a task at
  the submit gate — the merge layer has its own behind checks). Skipped for
  branchless roots and protected bases (master/main/-prefixed).

Tests: gate (6: refuse+steer/up-to-date/branchless/protected/fail-open-base/
fail-open-git), is_behind_base (6: parse/up-to-date/malformed/argv-form/
requires-branch/missing-project). ruff + mypy roboco/ tests/ clean; unit green.
2026-06-28 04:46:09 +02:00
Renn F 250be5c246 [feature] sync_branch dev verb — gate-level branch rebase (Phase B1)
Raw shell git is denied to agents (Bash(git:*) base deny), so a developer
whose branch fell behind its base had no gate-level rebase — only the
CEO/PM-only /rebase HTTP route. sync_branch is the dev verb that wraps the
rebase through the gate (traced + evidenced), so the 'everything goes through
the gates' invariant holds.

- lifecycle: IntentSpec sync_branch (dev-only, ownership-gated, composes=(),
  git-only — no DB transition); _next_hint_synced helper.
- GitService.sync_task_branch: rebase task.branch_name onto its resolved base
  via rebase_onto_base (fetch + rebase + force-with-lease push).
- Choreographer.sync_branch + _sync_branch_preflight_rejection: not_found /
  unknown-role / spec-gate / no-branch / protected-base guards, then the git
  op; conflicts abort (no force-push) and steer to resolve-by-hand; git failure
  steers to i_am_blocked.
- HTTP route /api/v1/flow/developer/sync_branch + SyncBranchRequest schema.
- MCP tool sync_branch(task_id) + _TOOLS registration (manifest auto-propagates
  via intents_for_role(Role.DEVELOPER)).

Tests: intent spec (5), choreographer handler (8: happy/conflicts/not_found/
not_authorized/no-branch/protected-base/git-failure/audit), route (1), MCP (1).
ruff + mypy roboco/ tests/ clean; unit suite green (DB-fixture errors env-only).
2026-06-28 04:40:06 +02:00
Renn F 6f8d0a4e0b [chore] mypy tests/: clear all 15 pre-existing type errors so make quality can go green
The branch tip had 15 mypy tests/ errors in files this bundle did not author,
which blocked CI's make quality mypy step (mypy roboco/ tests/) regardless of
the bundle's own commits. Pre-existing is still existing — fix every one:

- test_schemas_v1_flow.py (8): the StrList coercion tests intentionally pass
  SDK-nested list-of-strings input ([[['...']]], {'item':{'$text':'...'}}, int,
  dict). Annotate those literals as list[Any] locals so mypy accepts the
  coerce-able shape; the StrList BeforeValidator still flattens to list[str] at
  runtime. No type:ignore.
- test_pr_gate_records_verdict.py (3): notes_structured is dict|None; narrow
  with 'assert t.notes_structured is not None' before indexing (the existing
  pattern at line 90).
- test_pr_review_hand_format_guard.py (1 site, 2 errors): the _verb_runner()
  spy assertion — use the cc: Any = c alias idiom so assert_not_awaited
  resolves; drops the now-unused type:ignore[union-attr].
- test_pr_gate_notifies_pm.py (1): drop the unused type:ignore[method-assign]
  on the a2a.send reassignment.
- test_content_models.py (1): narrow coerced with isinstance(coerced,
  PrReviewContent) before reading .issues (the base _Content lacks the field).

Gates: rm -rf .mypy_cache && mypy roboco/ tests/ = Success (855 files);
ruff check + format clean; 5 affected suites = 40 passed.
2026-06-28 02:19:48 +02:00
Renn F e52fd05d59 [fix] submit_root: hard unchanged-PR gate stops the pr_fail re-submit loop
The 2026-06-27 infinite pr_fail loop: a Main-PM root (PR #139) was pr_fail'd,
routed to needs_revision, and re-submitted byte-identical → awaiting_pr_review
→ pr_fail again, forever. The prior hint/a2a steer was ignored by the weak
coordinator model — hints don't stop a model that won't read them. A HARD gate
refuses the re-submit when the assembled root PR's head SHA is unchanged since
the last pr_fail (no new cell work → identical diff); a different SHA ⇒ the
branch advanced ⇒ allow. Every ambiguous case fails open (no prior fail, no
recorded SHA, no pr_number, unresolvable slug, git error, closed PR) — only the
exact-unchanged case is hard-blocked.

- content/models: PrReviewContent.head_sha (optional; JSON col → no migration).
- git: get_pr_head_sha (GitHub pulls API; None on any failure → fail-open).
- pr_gate: pr_fail captures head_sha into the verdict record; pr_pass does not.
- _impl: submit_root runs _submit_root_unchanged_pr_guard after _submit_up_guard;
  _current_root_pr_head_sha resolves slug + current SHA (fail-open).
- pr_review: extract module-level resolve_task_project_slug, shared by the mixin
  and the gate helper (_LegacyChoreographer reaches it via cast to the
  ChoreographerHelpers typed view — it doesn't inherit the helpers mixin).
- tests: test_submit_root_unchanged_pr_guard (11 — refuse/allow/6 fail-open/3
  capture-side, mypy-clean via cc:Any spy idiom, zero type:ignore) +
  test_pr_gate_notifies_pm capture-path stub.
2026-06-28 02:02:50 +02:00
Renn F e202ce397d Fix: Make main_pm + task_type=code impossible 2026-06-27 19:52:01 +02:00
Renn F 5b931c367f Fix different project same PR number collision problem
Fix (two layers):
1. Root cause — pr_merge and rebase_pr_for_task now take a required project_id and scope the lookup where(pr_number == X AND project_id == Y). Required so no caller can forget — the bug class can't recur. All 4 call sites updated (choreographer cell_pm_complete, the rebase-retry, the superseded close_pull_request now passes project_id, and _verb_runner._do_pr_merge).
2. Crash guard — _finalize_cell_complete None-checks the complete() return and returns a clean invalid_state envelope (with a remediate hint) instead of dereffing None → 500 → respawn loop.
2026-06-27 07:19:35 +02:00
Renn F 53d60da37e Bunch of runtime fixes for MegaTask and other issues 2026-06-27 06:52:59 +02:00
Renn F 88d00aaa0a fix(pr-review): reject a verdict that contradicts the review's findings
post_pr_review (inbound external/fork PR review) derived both the recorded
notes_structured.pr_review.verdict AND the posted GitHub review event solely
from its `event` argument, which defaults to REQUEST_CHANGES — and, unlike the
in-path gate's pr_fail, it never required any findings. A reviewer that
concluded 'approve' in the summary but left event at the default filed (and
posted to the contributor's PR) a blocking 'changes requested' with nothing
cited, contradicting the approving summary the CEO saw on the PR Reviewer Notes
card.

Enforce a verdict<->findings invariant before any record or GitHub post:
- REQUEST_CHANGES must cite >=1 finding (almost always a forgotten
  event='APPROVE'), mirroring pr_fail's 'at least one issue' rule;
- APPROVE may not carry a blocker/major finding.
The check is the pure policy fn pr_review_conflict() wired through the new
choreographer _verdict_consistency_gate, rejected with a clear remediate hint.
Steer the agent at the source too: the flow MCP tool + request schema now spell
out the invariant and to pass event='APPROVE' explicitly for a clean PR.
2026-06-26 02:13:41 +02:00
5612375cba Feat/v0.13.0 (#270)
* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-26 01:43:08 +02:00
Renzo FandGitHub 99cf56dff3 [48849b22] Identify and fix the failing make quality step on roboco-api master (#260) (#261) (#262) 2026-06-25 17:17:46 +02:00
Renn F 2cce7d6a9f fix(pr-gate): land gate verdict on product-scoped PRs + persist it to notes
Two in-path PR-review-gate bugs surfaced reviewing the guard-core-app recovery
roots (PR #107 / root fead4372):

1. No verdict comment reached the PR. _project_slug_for returned None whenever
   project_id was None — but a Main-PM coordination root (the only task a
   root->master PR ever sits on) carries just a product_id (the cell->repo map),
   so _post_gate_review_to_pr resolved a None slug and silently no-op'd. It now
   falls through to the product's first distinct project (mirrors
   GitService._project_for_task), so the gate verdict actually lands on the PR.

2. The task's PR-reviewer notes contradicted the transition. pr_pass / pr_fail
   only threaded their notes through the tracing-gate shim and posted to GitHub;
   nothing wrote notes_structured.pr_review. A root passed once and later failed
   kept showing verdict=passed while the real transition was pr_fail. The gate
   now authors the canonical pr_review note on every decision (pr_pass -> passed,
   pr_fail -> failed), best-effort so a malformed note never rolls back the gate.

Adds unit tests for the product-slug fallback and the verdict persistence.
2026-06-25 10:54:31 +02:00
60c64c70e8 fix(run-hardening): break PM decision-gate, stale-agent, and empty-diff loops (#255)
Forensic triage of a 24h run reconstructed the dominant gateway.rejected
loops from the audit_log. After earlier deploys fixed the i_will_plan crash
and the open_pr push-gap, three real, recurring-capable burn loops remained.
This fixes them at the architecture level, not by prompt-nagging.

journal:decision write-then-gate (the dominant completion-path blocker):
PM decision-point verbs required a separate note(scope='decision') call
before the verb, which loaded/weak models forget to chain — so complete and
unblock hit a tracing_gap (journal:decision missing) and respawn-looped,
stranding finished tasks forever. Each verb now auto-records its OWN
rationale as the journal:decision before the gate runs (the proven
i_am_blocked -> write_struggle pattern), so the gate passes off real,
persisted reasoning. unblock gains a required `reason` (threaded MCP tool ->
request schema -> routes -> choreographer); delegate derives the decision
from its title + description; complete/submit_up/submit_root/escalate_up/
escalate_to_ceo reuse their existing notes/reason. The gate still runs as
defense-in-depth; the auto-record is idempotent within the decision window
and best-effort. Adds JournalService.write_decision and
Choreographer._ensure_pm_decision.

open_pr empty-diff 422: an overlapping-decomposition leaf with zero commits
vs its base makes GitHub 422 "No commits between ...". The generic
invalid_state "retry" looped the dev 15x on one task. open_pr now steers to a
terminal i_am_blocked hand-off so the PM completes or cancels the redundant
leaf.

owns_task stale-agent loop (41x): a superseded agent (task reassigned away)
calling i_am_done/open_pr got a PRECONDITION_OWNERSHIP tracing_gap it read as
a fixable precondition and retried forever. Both verbs now short-circuit with
the clear not_authorized "no longer yours -> give_me_work" steer that
resume/unclaim already use.

RAG docs updated for the new unblock(reason) signature; CHANGELOG entries
added under 0.11.0 (unreleased). open_pr refactored into
_open_pr_preflight_rejection + _open_pr_failure_env to stay within the
return-count and complexity budgets.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-25 01:07:05 +02:00
2bd35e1c9e fix(run-hardening): stop three blocked-task respawn loops (#253)
* fix(run-hardening): stop three blocked-task respawn loops

Three independent fixes for blocked-task respawn loops observed in the live
run (the bleeders behind a wedged near-complete run):

- verb runner: re-check the working task after EACH composed atomic action,
  not just at entry. A concurrent transition between a verb's precondition
  gate and execution (e.g. a racing i_am_blocked moving a root from
  needs_revision to blocked) made claim() return None mid-sequence; the next
  composed step dereferenced None.id and crashed with the opaque
  "'NoneType' object has no attribute 'id'", looping the PM. Now fails fast
  with an actionable INVALID_STATE; the savepoint rolls the partial run back.

- blocker dispatch: never dispatch a Board role (product-owner / head-
  marketing) as a blocker resolver. Board roles have no unblock verb, so the
  dispatcher respawned one forever to "resolve" a blocker it could only
  notify/triage about — one incident burned ~6400 tool calls on a single
  mis-owned root. _blocker_resolver_slug now returns None for a Board
  assignee so the dispatch skips it.

- git push: recover a missing local task-branch ref from origin/<branch>
  before push-by-name. A re-provisioned shared clone can lack the branch
  locally though its commits are on origin, so push died on
  "src refspec <branch> does not match any" and the task wedged at i_am_done.
  Now materializes the ref (no-op push when already on origin) or fails loud
  with an unclaim+reclaim instruction when the work is on neither.

Adds regression tests for all three. Full no-DB gate green (ruff, reflow,
mypy, xenon); pytest+coverage validated by CI.

* fix(verb-runner): only raise on an INTERMEDIATE composed None, not the last

The mid-composition None-guard was too aggressive: it raised for a None
returned by the LAST composed action too (e.g. start()), preempting the
caller's existing `if task is None` handler that surfaces the verb-specific
message ("start failed for task ...", the board verb's decline envelope).
Three tests asserting those messages broke in CI.

Only an INTERMEDIATE None is fatal (the next action would deref None.id). A
None from the last action is the verb's own result and must flow out as the
runner's return value. Guard now fires only for position > 0, before the
next dispatch — still prevents the crash, preserves the last-action contract.

* fix(escalation): never hand a Main-PM coordination root to the Board

The upstream cause of the board catch-22 (which the orchestrator-side
blocker-dispatch guard only backstopped): the escalation chain points
main-pm -> product-owner, and i_am_blocked/escalate REASSIGNS the task to
that chain target. apply_escalation's board-advisory guard only refused
descendant cell tasks (both predicates require parent_task_id), so a
top-level Main-PM coordination root slipped through and the whole root was
reassigned to the Product Owner + marked blocked. The board has no unblock
verb, so it spam-notified the CEO and respawn-looped (~6400 tool calls on
one root).

Add _is_coordination_task (team == main_pm — covers a delivery root AND a
MegaTask root-subtask) and a shared _board_cannot_own predicate, applied at
all four board-refusal sites (escalation, reassign, reassign_active_claim,
dependency-revival). A main_pm coordination task escalated/reassigned onto a
board role is now diverted to the pool for a role-matched (Main-PM) reclaim.

Complements the blocker-dispatch backstop in the prior commits (defense in
depth). Tests: coordination-root predicate cases + apply_escalation divert;
existing teamless-root / board-root behavior unchanged.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 15:43:30 +02:00
Renn F 855e4aea54 fix(gateway): guard the verb runner against a None task/agent
The runner's atomic steps dereference task.id / agent.id with no None-check, so a
verb invoked when the task or agent could not be resolved crashed with a cryptic
"'NoneType' object has no attribute 'id'" (observed on i_will_plan for a task
forced into an unexpected state out-of-band). Fail fast at run_intent's entry
with an actionable INVALID_STATE error instead.
2026-06-24 03:40:23 +02:00
889f3689e7 MegaTask (#248)
* feat(batch): batch_id + collision descriptor columns

Sequenced batch intake ("Mega task") foundation: tasks.batch_id (indexed)
groups a batch of top-level tasks created together; intends_to_touch (text[]),
adds_migration and touches_shared (bool, NOT NULL default false) are the
per-task collision surface the SequencingService will read to wire dependency
waves. Mirrored on the Task model + TaskCreateRequest and wired through
TaskService.create. Migration 046 (real upgrade->downgrade->upgrade verified
vs a throwaway pgvector PG); a non-batch task declares no surface (defaults).

Task 1 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): flag + draft collision descriptors

Default-off ROBOCO_BATCH_INTAKE_ENABLED (config + FEATURE_FLAGS + panel card);
the propose_draft tool doc + the TS DraftProposal gain the per-task collision
surface intends_to_touch / adds_migration / touches_shared. The draft is a loose
dict so the descriptors ride it through the relay intact (test asserts the
forwarded payload); the analyzer (Task 3) reads them to wire dependency waves.

Task 2 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): deterministic collision-sequencing analyzer

SequencingService.analyze turns a batch's per-task collision surfaces into a
dependency DAG + execution waves — correctness in CODE, not agent judgment.
Rules in order: file overlap serializes (more-important first), migrations form
a serial chain (no concurrent Alembic heads), touches_shared runs last, cell
contention warns (never serializes); then dedupe, existence + cycle check, and
Kahn topological layering. Pure (no DB/services); SequencingError on a cycle or
out-of-range edge.

Golden test reproduces the CEO's hand-sequenced 4 waves of the 11-item
guard-core-app batch (the effort that deadlocked the Main PM): S6 alone last,
the R1/R3/R4 migration chain, R2/R3/S8 serialized on the shared threat service,
S1/S2/S7 in one parallel wave.

Task 3 of the 0.11.0 sequenced-batch-intake plan.

* chore(batch): brand the user-facing surfaces "MegaTask"

The user-facing name is MegaTask: the feature-flag label is "MegaTask intake",
the panel flag-card and the config description lead with MegaTask. Internal
names stay technical (batch_intake_enabled, batch_id, SequencingService).

* chore(batch): drop the feature flag — MegaTask is a core intake scope

MegaTask is additive and opt-in by its own nature (the Prompter proposes a
batch only when the CEO asks for several tasks; single-task intake is
unchanged), so there is no risk surface a flag protects — 'don't create a
MegaTask' is the off switch. Remove batch_intake_enabled from config, the
FEATURE_FLAGS registry, the panel flag card, and its tests. MegaTask will be
a third scope option in the Intake modal (single-cell / multi-project /
MegaTask), not a toggle.

* feat(batch): MegaTask identity predicate + orchestrator branchless recognition

The single source of truth for the umbrella's exemptions: pure
is_batch_umbrella / is_batch_root_subtask / is_branchless_coordination
(foundation/policy/batch.py) — an umbrella has a batch_id and is top-level; a
root-subtask shares the batch_id but is parented. The orchestrator's
_is_coordination_task now consults is_branchless_coordination, so a MegaTask
umbrella is recognized as doing no git of its own (git-exempt at spawn-readiness
/ stuck-detection) exactly like a product fan-out root. Non-batch behavior is
identical (the predicate reduces to the old no-project+product check; the
orchestrator coordination suite stays green), and the umbrella branch is inert
until the create path exists.

First slice of the MegaTask umbrella enforcement (branchless guard).

* feat(batch): branchless umbrella guard across the git-exemption sites

A MegaTask umbrella does no git of its own — every git-exemption site in
TaskService now consults the shared is_branchless_coordination predicate
instead of an inline product-only check, so the umbrella's exemptions
cannot drift between sites:

- the claimed->in_progress branch gate (GitContext.is_coordination) lets
  an unbranched umbrella reach in_progress and delegate;
- _ensure_branch_for_task short-circuits an umbrella to "" instead of the
  misconfigured raise (the claim path ignores the return, treating it as
  branchless);
- CEO-reject routing sends a rejected umbrella to the Main PM in PENDING
  (needs_revision is developer-claim-only and would deadlock it).

Covers both shapes via the predicate (product fan-out root OR umbrella);
a batch root-subtask keeps its own branch/PR. Adds orchestrator
recognition tests for the umbrella plus claim/branch/reject integration
tests.

* feat(batch): umbrella assembles no PR; completes branchless

submit_root now hard-rejects a MegaTask umbrella up front (a preflight
that also folds in the unknown-role refusal to stay within the
return-count budget): the umbrella spans many projects with no single
master, so each root-subtask opens and is reviewed on its own PR — the
umbrella never enters the in-path review gate. The Main PM completes it
directly once every root-subtask is terminal.

Umbrella completion needs no new code: it is branchless (no branch_name),
so _main_pm_complete_guard already accepts it from in_progress, checks
all_subtasks_terminal, and main_pm_complete walks it to awaiting_pm_review
and escalates to the CEO with no PR creation — exactly the product
fan-out root path. Adds the submit_root-reject and umbrella-completion
gateway tests; pins batch_id=None on the normal-root submit_root test
(a MagicMock auto-attr would otherwise read as an umbrella).

* feat(batch): MegaTask create path — umbrella + sequenced root-subtasks

PrompterService.confirm_live_batch turns N confirmed drafts into a real
MegaTask: it builds each draft's collision surface, runs the pure
SequencingService to get conflict-free waves, creates the branchless
umbrella (batch_id, no project/product), then one root-subtask per draft
(own project, parent=umbrella, sequence=wave index, descriptors), and
wires the analyzer's edges through add_dependency so the existing
dependency-gate runs the waves in order. The route picks the start path
like a single confirm: 'board' holds the root-subtasks in BACKLOG for the
batch review; 'main_pm' creates them PENDING so wave 0 dispatches at once.

create_task_from_draft gains a BatchPlacement (parent/batch/sequence/
team_override) and forwards the collision descriptors; the exactly-one-
target rule (here and the TaskService.create invariant) is relaxed for an
umbrella, which legitimately targets neither. New route
POST /live/{session}/confirm-batch + BatchConfirmRequest mirror the single
confirm. Adds the structural-invariant + board-hold + empty-batch tests.

* feat(batch): release MegaTask root-subtasks on CEO approval; board awareness

The board route holds a MegaTask's root-subtasks in BACKLOG so the work
waits for the batch review. approve_and_start (CEO gate #1, board->Main PM)
now releases them via _activate_batch_root_subtasks: each held child flips
BACKLOG -> PENDING + team=main_pm so the dependency-gate dispatches wave 0.
No-op for a non-umbrella; idempotent (children past BACKLOG untouched).

The Product Owner and Head of Marketing identity prompts gain a MegaTask
section so they review the whole batch + wave plan and adjust scope before
sign-off (they review drafts; the umbrella is their unit). Also extracts
the create() target invariant into _require_target_or_umbrella to keep the
method under the complexity gate after the umbrella exemption. Adds the
umbrella-approval activation test.

* feat(batch): multi-project intake scope for MegaTask

A MegaTask spans several possibly-unrelated repos, so the intake chat can
now be scoped to an explicit project list (not just one project or one
product). StartLiveRequest gains project_ids; /live/start threads it
through start/spawn_intake_session -> _spawn_intake_container ->
_clone_intake_scope. The multi-repo clone machinery already existed for
products; _intake_scope_slugs now also resolves an explicit project_ids
set (split into _slugs_for_project_ids / _slugs_for_product), cloning each
repo with the first as the primary cwd and the siblings readable. Scope
validation is now 'exactly one of project_slug / product_id / project_ids'
via the shared _require_one_intake_scope. Adds scope-resolution, spawn,
and route tests for the MegaTask path.

* feat(batch): propose_batch intake tool (MegaTask multi-draft hand-off)

The intake agent can now hand the panel a whole MegaTask in one tool call.
Both intake paths gain propose_batch alongside propose_draft:
- Claude (intake_driver): a propose_batch tool registered on the in-SDK
  MCP server + allowlisted; the driver intercepts the ToolUseBlock and
  emits ONE StreamChunk(kind="batch") carrying {drafts:[...], title}.
- grok (intake_server): a propose_batch tool that POSTs a "batch" relay
  event via the shared _post_event helper (post_draft/post_batch).

A batch carries N drafts, each the propose_draft shape PLUS its own
project_id (a MegaTask spans unrelated repos) and collision surface so the
analyzer sequences the waves. The prompter prompt documents the MegaTask
scope + when to call propose_batch. Adds Claude-normalize and grok-relay
tests for the batch path.

* feat(batch): MegaTask intake panel — third scope, batch review, waves

The panel now drives a MegaTask end to end. The intake modal gains a
third scope, 'MegaTask', beside Single cell and Board-led: a multi-project
checklist (a MegaTask spans several possibly-unrelated repos), validated
to at least two. start() sends project_ids; use-prompter accumulates the
agent's single propose_batch hand-off as a 'batch' SSE event into a
BatchProposal and lands in a new batch_preview state.

A new BatchReviewCard lists every proposed task with its target project +
collision-surface badges (migration / shared) and offers one start path
for the whole batch — Board review & Start or Approve & Start — wired to
confirmBatch → POST /confirm-batch. The success card shows the sequenced
result: N tasks in M waves (+ any advisory notes). prompter.ts gains the
DraftScale 'megatask' + the BatchConfirm payload/result types; the SSE
client allows the 'batch' kind. Panel typecheck + lint + 113 tests green.

* docs(batch): MegaTask across changelog, CLAUDE.md, site, and RAG

The four documentation obligations for the MegaTask feature:
- CHANGELOG: an Unreleased entry covering the umbrella model, sequencing,
  multi-project intake, propose_batch, and the create/approval path.
- CLAUDE.md: a MegaTask section (identity predicate, umbrella/root-subtask
  hierarchy, sequencing rules, intake + create path, board activation).
- Published site: a user-facing company/megatask.md (scopes, waves, the
  umbrella, the two start buttons) + nav entry; a pointer added to the
  intake chapter of the Tour.
- RAG corpus: workflows/megatask.md so the Main PM (and any agent) can
  retrieve the umbrella's branchless / no-PR / completion rules at runtime.

The runtime concurrent-migration guard is intentionally NOT added: the
analyzer already chains migration-adders into dependencies and the
dependency-gate serializes them, so a separate guard would be dead code.

* feat(batch): batch_id guardrail + wave preview + batch_id on TaskResponse

Guardrail (CEO): a batch_id is denied on any task that is not a well-formed
MegaTask member. is_valid_batch_shape permits batch_id only on an umbrella
(no parent → must target neither project nor product) or a root-subtask
(has a parent → exactly one target); TaskService.create enforces it AND
verifies a root-subtask's parent is the batch umbrella (same batch_id,
top-level). This closes a latent hole: is_batch_umbrella is true for a
batch_id + no-parent task even with a project, so a stray batch_id could
have spoofed the branchless branch-gate / no-PR exemption. (The public
task API never exposed batch_id for write; this guards the service layer.)

Wave preview: PrompterService.preview_batch + POST .../preview-batch
compute a MegaTask's waves from the proposed drafts WITHOUT creating
anything, so the panel can show the sequencing before confirm. Extracted
_sequence_drafts as the single source shared by preview and confirm, so
the previewed waves are exactly the ones wired.

TaskResponse now carries batch_id so the panel can badge the umbrella.

* feat(batch): MegaTask review — project editor, wave preview, persistence, badge

Closes the panel gaps in the MegaTask review experience:
- Per-task project editor: each proposed task gets an inline project
  Select (updateBatchDraftProject), so a task the agent put in the wrong
  or no repo can be fixed before launch — not only by re-chatting. Launch
  stays blocked until every task has a project.
- Wave preview: on a batch proposal the panel fetches POST .../preview-batch
  (no task created) and shows the conflict-free wave plan, so the human
  reviews the sequencing before confirming.
- Refresh durability: the MegaTask review (batch + waves + projectIds) is
  persisted, so a browser reload mid-review restores it like a single draft.
- MegaTask badge: TaskResponse exposes batch_id, the panel Task type
  carries it, and the task table badges the umbrella row 'MegaTask'.

Panel typecheck + lint + 113 tests green.

* test(batch): stub task carries batch_id for task_to_response

task_to_response now serializes batch_id (TaskResponse field), so the
_stub_task SimpleNamespace fixture must provide it — without it the reader
hit AttributeError, failing the 8 task-schema serialization/enrichment
tests. Test-only; the real TaskTable carries the column (migration 046).

* fix(batch): close MegaTask audit gaps — completion crash, analyzer cycle, guardrails

An adversarial multi-agent audit of the feature surfaced 20 verified gaps;
this closes the backend ones.

HIGH:
- Umbrella completion crashed. escalate_to_ceo hard-required a pr_number,
  which a branchless umbrella never has, so main_pm_complete dereferenced
  None. Both pr_number gates now waive a MegaTask umbrella (escalate_to_ceo
  + the awaiting_pm_review->awaiting_ceo_approval lifecycle gate via a new
  GitContext.is_umbrella), and main_pm_complete guards a None return. The
  completion test had mocked escalate_to_ceo, hiding it — now a real
  service test covers the waiver.
- The collision analyzer could fabricate a cycle (a touches_shared +
  adds_migration draft overlapping another migration draft) and raise
  SequencingError — a bare ValueError that escaped as an opaque 500. The
  migration chain is now shared-last-aware (never contradicts rule 3), and
  _sequence_drafts translates SequencingError to a clean 400.

MEDIUM:
- Collisions are now project-scoped: two repos can't collide on a
  coincidental path or serialize independent migrations (DraftSurface
  carries project_id; rules 1/2/3 respect it).
- The batch_id guardrail ran only at create. update() + the PATCH
  null-clear path now re-assert is_valid_batch_shape, so a mutation can't
  break a member's shape and spoof the branchless exemption.
- A draft missing title/acceptance_criteria now raises ValidationError
  (was a bare KeyError -> 500).
- confirm_live_batch re-asserts every draft targets a scoped project and
  the batch spans >=2 distinct projects (project_ids added to the request).
- Route-level tests for confirm-batch / preview-batch.

LOW: strict multi-repo clone (fail loud on any unresolvable project);
malformed/empty propose_batch surfaces an error chunk (Claude) / refuses
to POST (grok) instead of silently acking; dropped malformed drafts are
counted and surfaced; stale grok intake docstrings updated.

* fix(batch): MegaTask panel + doc audit gaps

Frontend half of the audit fixes:
- The confirm payload now carries project_ids (the schema requires it), and
  the panel re-checks every task targets one of the scoped repos before
  launching, naming the offending task.
- The Review-MegaTask project picker is filtered to the scoped repos and
  the per-task validity (border + launch gate) keys off scoped membership,
  so a task can only be (re)pointed at an in-scope project — also fixing the
  case where the agent emitted a non-UUID / unknown project.
- Dropped malformed drafts are surfaced as a chat error so the human knows
  the batch shrank instead of silently confirming fewer tasks.
- Doc wording: a wave releases on the previous wave's terminal state
  (normally a merge; a cancellation releases it too), not strictly 'merged'.

* test(batch): lock the CEO's EXACT 4-wave hand-sequencing as the golden bar

The golden test asserted the constraints (S6 last, the migration chain, the
shared-threats serialization, S1/S2/S7 parallel) but not the full wave
partition. The bar for MegaTask is 'reproduce my exact waves or it's not
done', so assert the exact 4-wave partition the analyzer produces for the
guard-core-app batch:
  wave 1: R1 R2 S1 S2 S3 S5 S7  ·  wave 2: R3  ·  wave 3: R4 S8  ·  wave 4: S6
Confirmed unchanged by the audit's analyzer fixes (no migration is shared;
single project).

* fix(batch): tolerate a stub task in assert_batch_shape_intact

The batch-shape re-validation read task.batch_id directly, but update()'s
partial-caller contract is exercised with a SimpleNamespace stub that has no
batch_id column → AttributeError. Use getattr(..., None) for batch_id and the
shape fields so the guard no-ops on any task lacking the column (a stub, or a
non-batch task) while still enforcing on a real batch member.

* fix(orchestrator): authenticate internal API self-calls with the system identity

The dispatcher httpx clients were built without an agent identity, so the
orchestrator's self-PATCHes to /api/tasks/{id} (auto-block, auto-resume,
auto-recover, SLA annotation) were rejected 401 "Missing X-Agent-ID" and
silently no-op'd. The auto-resume that lifts a PM's paused parent could never
write, so paused/blocked parents stayed wedged and stranded their dependents
(the fe-pm/be-pm respawn churn seen in prod).

Header propagation was inconsistent across the separate AsyncClient call-sites:
only the main dispatch client carried the system identity; the readiness and
sweep clients did not. Hoist the identity into a shared _SYSTEM_API_HEADERS
constant and apply it to every API-facing dispatcher client. The system role
holds TaskAction.ASSIGN, so it is authorized for the audited admin_set_status
path those write routes use. The external provider-recovery probe client is
intentionally left untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 01:15:57 +02:00
c09cf80b40 Feature/observability gateway health (#247)
* feat(observability): revision_count + audit_log query index (migration 045)

Adds tasks.revision_count (the O(1) rework counter — forward-only, existing
rows default 0) and the composite index audit_log(target_id, event_type,
timestamp) that powers the cycle-time and rework reconstruction queries.
Verified the real upgrade/downgrade/upgrade chain on a throwaway pgvector PG.
First task of the 0.10.0 observability dashboards.

* feat(observability): count reworks + attribute qa_fail/pr_fail to the rejector

Every transition into needs_revision increments tasks.revision_count at the
single audit chokepoint (exactly once per bounce, across all paths incl. pr_fail
and ceo_reject), so the rework rate is an O(1) read. A QA or PR-review bounce
also emits a named task.qa_fail / task.pr_fail audit event carrying the
rejector's agent_id, so the per-agent rework scorecard charges the rejection to
the reviewer who made it, not the developer who owns the task.

* feat(observability): cycle-time, bottleneck, rework, and scorecard metrics

MetricsService gains four read methods on the audit_log + tasks data: per-stage
cycle time reconstructed from the transition journey (excluding the named
qa_fail/pr_fail events), bottleneck distribution (cumulative dwell + live parked
counts), rework rate (overall/by-team/by-agent with rejector attribution + cost
via spawn-session task_id), and a fused per-agent/per-cell scorecard. Dataclass
models with to_dict(). Verified against a real Postgres journey.

* feat(observability): cycle-time/bottleneck/rework/scorecard read endpoints

Thin read-only routes on the dashboard router delegating to MetricsService:
/metrics/cycle-time, /metrics/bottlenecks, /metrics/rework, and
/metrics/scorecard/{agent,team}. 404 when an agent scorecard target is absent.
5 route tests (200 + shape + the agent-404 case).

* feat(panel): Delivery observability tab (cycle-time, bottlenecks, rework, scorecards)

A third Metrics tab built on the observability endpoints: a per-stage
cycle-time bar chart, a bottleneck panel (worst stage + cumulative dwell +
live parked counts), a rework panel (rate + by-team + by-agent attribution +
cost), and per-cell scorecards. Reuses Recharts + Card/Badge/Skeleton and the
React-Query hook pattern; observabilityApi mirrors usageApi with mock-mode
fallbacks. tsc + eslint clean; 113 panel tests pass.

* docs(observability): changelog + CLAUDE.md for the delivery dashboards

* feat(gateway-health): recover a broken-but-alive agent instead of protecting it

The verb-heartbeat cannot tell a quiet-healthy agent from one whose MCP gateway
is broken (a corrupted /app/.venv firing no verb) yet whose container is up — the
reaper's live-skip would shield it forever. The reaper now probes the gateway
out-of-band (docker exec: does the gateway venv import its deps?) and, once it
has been broken past gateway_health_grace_seconds (tolerating a transient probe
miss), kills + evicts the container so it falls through to release + respawn.
Probe-inconclusive or healthy spares the container. Gated by
gateway_health_enabled (default-on reliability fix; in the panel Feature Flags).
Defers the optional agent-side self-check + full registry re-adoption — the
reaper's docker-liveness fallback already recovers a broken-after-restart agent.

* docs(gateway-health): changelog + CLAUDE.md for broken-but-alive recovery

* docs(observability): user-facing docs for the Delivery dashboards + gateway-health

Documents the new Metrics -> Delivery tab (cycle-time, bottlenecks, rework with
rejector attribution, cell scorecards) in the panel guide and the operations
health-and-metrics guide, and adds the gateway-health env vars + an agent-gateway
recovery note. Published MkDocs site only; settings.md's default-off flag table
intentionally omits the default-on gateway-health flag (same as overload-break).

* chore(release): cut 0.10.0 (changelog section + version refs)

* fix(gateway): exempt PM coordinators from single-task claim guards

A Main/Cell PM plans and delegates many root tasks in parallel; the work
then runs in the delegated cells, not in the PM's own hands. But the
claim-time concurrency guards meant for developers — already_active and
paused (the latter firing after i_am_idle auto-pauses the PM's own
umbrella) — were applied to the PM too, so once it held one root it could
never plan a second: it thrashed between its claimed roots and respawned
forever, burning tokens for zero progress.

_run_claim_guards now skips already_active/paused for the coordinator PM
roles (_COORDINATOR_ROLES = {main_pm, cell_pm}); only unmet_dependency — a
real upstream sequence constraint, which parks the root back to pending —
still gates a PM. paused_tasks_guard also excludes the target task itself,
so a PM re-entering its own paused umbrella never self-blocks.

Tests: a coordinator plans a second root with one in_progress + one paused
sibling (full path + claimed-recovery path), the paused target exclusion,
and the developer guards still fire. Repurposed the pre-fix test that
asserted the now-removed PM block.

* fix(metrics): coerce SQL avg/extract hours aggregates to float (panel toFixed crash)

EXTRACT(epoch ...) returns numeric on PostgreSQL 14+, which asyncpg surfaces
as a Decimal; a Decimal serializes to a quoted JSON string, so the panel's
avg_cycle_hours.toFixed(1) (and the other hours fields) threw 'toFixed is not
a function' and blanked the Delivery tab.

A single _as_hours helper now rounds every SQL-averaged hours field to a real
float — avg_cycle_hours on the new scorecards plus the pre-existing
avg_completion_hours / avg_blocked_hours / longest_blocked_hours. Token and
cost fields were already float()-cast and are unaffected.

Regression test asserts _as_hours coerces Decimal -> float and preserves the
None/zero behavior.

* feat(panel): edit a task's sequence from the details page

A task's sequence (order within siblings, lower runs first) was display-only
with no way to change it from the UI, and TaskUpdate didn't carry the field
so PATCH couldn't set it either. The details page's Dependencies tab now has
an inline sequence editor mirroring the parent / dependency editors, and
PATCH /tasks/{id} accepts a sequence field (owner or privileged role) through
the existing generic update path.

* fix(mypy): green the full make-quality type gate

make quality runs 'mypy roboco/ tests/', which the per-module checks on the
0.10.0 branch never exercised. Two issues surfaced:

- The coordinator-exemption change added role_str to
  Choreographer._run_claim_guards but not to the ChoreographerHelpers
  protocol base, so the composed Choreographer had incompatible base-class
  signatures. Sync the protocol signature.

- The gateway-health / stale-reaper tests stubbed methods by direct
  assignment (orch._m = AsyncMock()) and typed their duck-typed task doubles
  as object, tripping method-assign / assignment / attr-defined. Switch to
  monkeypatch.setattr (keeping a local mock ref for the assertions) and type
  the doubles as Any — no type: ignore.

Full mypy roboco/ tests/ clean (785 files); the 21 runtime tests pass.

* fix(metrics): static cycle-time SQL — clear bandit B608 (CI gate)

The cycle-time query interpolated an optional team clause into the text() SQL
via an f-string, which bandit flags as B608 (hardcoded SQL) and turned the
merge gate red. The team value was always a bound parameter, so it was a false
positive — but the f-string is the trigger. Rebuilt as one static query with
(CAST(:team AS text) IS NULL OR a.details->>'team' = :team) and an always-bound
team param (CAST, not ::text — SQLAlchemy's :param parser collides with
PostgreSQL's :: cast operator, which broke the query as a stray param).

Full make quality green vs a real pgvector PG (all 21 gate steps).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-23 07:26:41 +02:00
Renn F ebc3b1ea18 fix(docs): push the documenter's doc commit so it lands in the PR
The documenter writes and commits docs onto the task branch in its own
workspace clone, but i_documented had no push step — so the commit stayed
local and the PM merged the already-open PR without the docs, which then
vanished on merge. i_documented now pushes the task branch before handoff,
mirroring the developer's _ensure_branch_pushed; a push failure holds the
task in awaiting_documentation for a retry instead of silently dropping the
docs. Extract _finalize_documented to keep the verb under the return-count
ceiling.
2026-06-23 00:39:08 +02:00
Renn F 0a890c401e fix(gateway): surface an 'unknown' toolchain status at the gate instead of passing silently
The toolchain guard fails open on a recorded 'unknown' status (precision over recall — never strand a task on an inconclusive smoke). But an 'unknown' means provisioning ran yet the smoke could not confirm the suite is collectable under the interpreter, so the gate was proceeding blind with no trace — a silent hollow pass. It now emits a 'toolchain.unverified_gate_pass' warning with the agent and task ids when proceeding past 'unknown', while still not blocking; a missing marker (None) stays silent so the warning carries signal.
2026-06-22 13:06:55 +02:00
16789c1ca7 Feature/architectural conventions standard (#243)
* feat(conventions): standard schema models + effective-map merge

* feat(conventions): tree-sitter Python classifier + placement checks

* feat(conventions): TS classifier, hygiene/custom checks, runner + CLI

* feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration

* feat(conventions): repo auto-scan + scaffold draft renderer

* feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore)

* feat(conventions): auto-scaffold on project registration (flag-gated)

* feat(conventions): TaskDescription.constraints + auto-baseline attach

* feat(conventions): ambient architecture-map injection at spawn

* test(conventions): subprocess CLI smoke for the agent-image entrypoint

* feat(conventions): block i_am_done on block-level convention violations

* feat(conventions): block pr_pass on unresolved convention violations

* feat(conventions): surface convention findings into QA evidence

* docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer

* feat(conventions): panel Conventions tab + flag toggle + parity

* test(conventions): end-to-end block, fix, and waiver through the gate

* refactor(conventions): extract pr_pass guards to keep pr_gate under the gate

* style(conventions): format the baseline-constraints attach in task.create

* test(conventions): type-annotate test helpers for the full mypy gate

* build(conventions): ignore types-PyYAML in deptry (mypy-only type stub)

* docs(conventions): document the standard in CLAUDE.md + PM prompt awareness

* fix(conventions): baseline constraints are non-suppressible (dedup-append)

* feat(conventions): scaffold on first workspace clone (threaded workspace)

* feat(conventions): multi-project ambient map for PO/Intake (per-product)

* feat(conventions): persist findings + violations-feed route (migration 044)

* feat(conventions): panel violations feed in the Conventions tab

* test(conventions): intake-spawn mock accepts the ambient layer kwarg

* fix(docker): ollama-init best-effort pull, gate startup on cached models present

A degraded/slow ollama registry made the model manifest re-check fail under
set -e, so ollama-init exited 1 and blocked the orchestrator's
service_completed_successfully gate — taking the whole stack down even though
both models were already cached. Pulls are now best-effort; success is gated on
the models being present, so a flaky registry can't down a cached deployment.

* refactor(content): drop dead TaskDescription.with_baseline_constraints

The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-22 12:37:46 +02:00
Renn F 7d50405ccb fix(toolchain): type-annotate test helpers for the full mypy gate
make quality runs mypy over tests/ too; the toolchain test fixtures
(monkeypatch params, the fake-subprocess factories) were missing annotations.
No behavior change.
2026-06-22 01:59:41 +02:00
Renn F 0ea9f48187 feat(toolchain): block QA/dev/PR gates when the suite cannot be executed
Adds _toolchain_broken_guard: when toolchain matching is on and the acting
agent's workspace recorded a 'broken' status (the project's suite cannot be
collected under the provisioned interpreter), i_am_done, pass_review, and
pr_pass refuse with an i_am_blocked remediation instead of letting the role
"pass" on a source read. fail_review / pr_fail stay available. git gains
toolchain_status_for_task (resolves the actor's workspace, reads the marker,
fail-open None). Inert when the flag is off; a missing/unknown status never
strands a task.
2026-06-22 01:49:44 +02:00
Renn F 8cf697816f feat(content): obligate role note sections like journals
Completes the note(scope='handoff') write-path (WIP 23e6ee57): every role
with a dedicated note section is now obligated to populate it, the same way
journals are obligated.

Obligations (foundation.policy.tracing):
- DEV_NOTES / PR_REVIEWER_NOTES / QUICK_CONTEXT_MIN_CHARS requirements +
  checkers, wired onto i_am_done (dev_notes), delegate (quick_context), and
  pr_pass / pr_fail / post_pr_review (pr_reviewer_notes).
- Fixes a latent bug: the docs-notes checker read dev_notes instead of
  doc_notes (the documenter's section); the i_documented shim now feeds
  doc_notes to match.

Auditor: a session-scoped note obligation on i_am_idle — the auditor owns no
delivery task and has no delivery verb, so it must have recorded an
observation within the window before going idle (JournalService.has_recent_entry).

Write-then-gate: persisted sections (dev_notes / quick_context) are
pre-written by the agent's note(scope='handoff') before the gated verb;
argument-borne sections (doc_notes / pr_reviewer_notes) are checked through a
SimpleNamespace shim, the same pattern qa_notes already uses.

Config: dev/pr_reviewer/quick_context min-chars (40/40/30), panel-tunable.
Plus per-gap remediation hints and full coverage (write-path routing,
ownership, validation->remediation, each obligation, the doc_notes fix).

Full make-quality green: 9777 passed, 95.6% coverage.
2026-06-21 20:31:42 +02:00
Renn F 3a67fa4a0f fix(content): anti-soup guard on pitch slug + restore pitch test intent
Sweep found pitch slug was the one content-tool free-text field still outside
the anti-soup guard (a product could be slugged 'wip'/'asdf'). Add it to the
guard (min 2, so real short slugs like 'ui' pass). Also fix two pitch tests
that were false-passing on a 1-char title (the title guard rejected first):
give them substantive fields so they actually exercise the role gate
(not_authorized) and the non-cell-target rejection (invalid_state).
2026-06-21 08:08:28 +02:00
Renn F 8463808616 fix(lifecycle): let a PM recover its rejected coordination task from needs_revision
The in-path PR-review gate created a deadlock: when an assembled cell→root /
root→master PR fails the gate (pr_fail) — or qa_fail / ceo_reject fires — the
PM-owned coordination task lands in needs_revision, which was developer-claim-
only. So the task had no actor and no exit but cancel, and the cell PM escalated
in a loop (8KB of [ESCALATED] dev_notes on one task). Pre-gate, the PM simply
re-delegated from in_progress; the gate routed the failure through the PM's own
task instead.

Add NEEDS_REVISION to the CELL_PM / MAIN_PM claim rules so the PM re-claims via
i_will_plan, revises the plan, and re-delegates the fixes — pr_fail/qa_fail
already reassign the failed task to its owning PM and the revision dispatcher
re-spawns it; the claim rule was the only missing piece.

Scope is by give_me_work routing (offers only the caller's own assigned tasks),
the same mechanism that scopes a developer's leaf-revision — NOT a gateway-only
ownership gate, which would violate the spec=gateway parity invariant and can't
use task_type anyway (main-PM coordination roots can be code-typed). Regenerates
panel/lib/lifecycle.json.
2026-06-21 06:09:59 +02:00
Renn F 4c85cc6dfa feat(content): anti-soup guard on the flow verbs' free-text
Extends structured-content enforcement from the content tools to every
flow verb that carries agent free-text, closing the last hole where a
dev/PM could pass word soup: i_am_blocked(reason), i_am_done(notes),
submit_up/submit_root/complete(notes), escalate_up/escalate_to_ceo(reason),
pass_review(notes), fail_review/pr_fail(issues), pr_pass(notes),
i_documented(notes), delegate(title/description). Plans (i_will_plan /
i_will_work_on) keep their existing >=150-char approach + sub_task gates
and are skipped here so recovery re-entry with thin values still works.

Shared helpers on the choreographer: _free_text_soup (bare envelope, list
aware) and _soup_or_decision_env (folds the soup check into a verb's
existing spec-gate return so no verb gains a return or tips the xenon
bound). reject_trivial now also catches all-filler multi-token strings.

base.md documents the broadened rule for agents.
2026-06-21 05:11:20 +02:00
Renn F 96ca53eff2 feat(content): extend anti-soup guard to pitch title, open_session topic, pr_update, note narratives
The universal guard covered say/dm/note-text/progress/notify/pitch
problem+solution. Close the remaining content-tool fields the agent
authors: pitch title, open_session topic, pr_update title/body (when
supplied), and the decision/reflect narrative sub-fields of note
(rationale/context/what_done/...). Narrative fields are only checked
when the agent fills them — an omitted field keeps its tolerant
'(not provided)' placeholder so a thin note still records and never
trips the do-server circuit breaker. Fold pr_update's no-fields + soup
checks into one helper to stay under the return-count bound.
2026-06-21 04:46:28 +02:00
Renn F db74f546a3 feat(content): universal anti-soup guard on every agent free-text field 2026-06-21 04:20:07 +02:00