Commit Graph
497 Commits
Author SHA1 Message Date
Renn F 902fc9a78c chore(models): finish the opus-5 replacement — no stale opus-4 fixtures remain
Every fixture, comment example, and panel mock that presented an Opus 4.x
id as current now carries claude-opus-5 (or the current sonnet/haiku ids in
the panel usage mocks). The only deliberate claude-opus-4 survivors are the
pricing-table fragment and its tests — they price the historical usage rows,
which would otherwise re-read as $0.
2026-07-25 00:49:13 +02:00
Renn F 3c87596685 chore(models): move the opus alias to claude-opus-5
Claude Opus 5 released today — same $5/$25 sticker, 1M context; Opus 4.8
moves to legacy. The pricing table gains a dedicated claude-opus-5
fragment (the claude-opus-4 substring doesn't cover it, so without the
row the fleet's Opus usage would silently cost-track as $0 — exactly
what test_opus_is_priced now guards).
2026-07-25 00:41:13 +02:00
Renn F 803638e8e9 chore(models): upgrade the opus alias to claude-opus-4-8
MODEL_MAP["opus"] moves off claude-opus-4-6 to the newest Opus tier at the
same price; pricing already matched via the claude-opus-4 fragment, and a
new test_opus_is_priced guard keeps the alias priced. Fixtures, panel
mocks, and docs follow.
2026-07-25 00:13:15 +02:00
987eb09c78 fix(workspace): role-aware worktree refresh at every spawn (#692)
* fix(workspace): refresh a present per-task worktree at every respawn

ensure_worktree_self_heal treated an already-present worktree as a pure
no-op (venv-link + chown only), so a worktree created once at first claim
or first claim_review stayed frozen at that commit across every later
respawn even as new commits landed on origin — the root mechanism behind
a live multi-round QA/PR-gate bounce loop, where the reviewer kept
re-examining its own stale round-1 checkout.

_ensure_worktree_before_spawn now classifies the caller's role
(WORKTREE_AUTHOR_ROLES: developer/documenter, mirroring the gateway
commit tool's RBAC) and _refresh_present_worktree compares local HEAD
against origin/<branch>: behind-or-equal fast-forwards for every role
(never discarding an author's uncommitted edits to do it); strictly
ahead is always left alone; diverged only resets for a pure reader,
whose local history can never be anything but a stale prior-round
checkout.

conventions_check_for_task's list-vs-content gap (list from git objects,
content from the physical worktree) is closed as a side effect: the
reviewer's worktree is now current as of spawn, and the branch under
review gains no further commits while it sits in awaiting_pr_review.

* fix(workspace): refresh re-added worktrees; fail the dirty guard toward preservation

- A pruned worktree re-added from a surviving local ref now runs the same
  fetch-and-classify refresh as a present one, so an evicted reviewer
  worktree cannot resurrect a stale checkout.
- A failing git status reads as dirty, never clean: the guard that
  protects an author's uncommitted edits fails toward preservation.
- The hard reset verifies the worktree is actually on the task branch
  first; a detached or drifted worktree is left alone with a warning.
- The conventions-check docstring states the remaining second-claim
  ceiling instead of claiming full closure.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 20:29:20 +02:00
e97f46af6e fix(git): reviewer reads must prefer origin over a diverged local ref (#690)
* fix(git): reviewer reads must prefer origin over a diverged local ref

_resolve_head_ref (GitService.diff/list_changed_files/read_file_at_branch)
kept local priority on ANY divergence from origin, real or rewritten. A
reviewer's clone parked on pre-rebase history after the branch's routine
force-push sync stayed frozen there across every subsequent review round,
while origin held every fix commit — QA repeatedly bounced work that had
already landed. Every caller here is a reader, never the branch's own
author mid-write, so origin now wins whenever it carries anything the
local ref lacks; local keeps priority only when it strictly contains
origin (unpushed commits, or equal).

The read-only git MCP surface (roboco_git_log) hit the same staleness
through a separate path: /api/git/log resolved the requested branch as a
bare name straight off whatever the caller's own clone had on disk, with
no fetch at all. It now routes through the same fixed _resolve_head_ref.

* test(e2e): give the armed flow-verb timeout real headroom

The armed value is also verb-2's entire execution budget (claim + every
claim guard + set_plan + start + tracing gate), which grows as guards
land; 1s flaked on loaded CI runners while passing locally. The
cancel-and-release semantics only need the timeout far below the hang.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 17:50:13 +02:00
eb0dcb6ecb fix(orchestrator): task-scoped oscillation breaker for escalate/unblock ping-pong (#685)
* fix(orchestrator): task-scoped oscillation breaker for escalate/unblock ping-pong

An escalation ping-pong oscillates a task between two agents (cell PM
escalate_up -> BLOCKED -> main PM unblock -> restored -> respawn ->
escalate again). The per-(agent, task) respawn gate never trips on it:
the restored side is dispatched by _dispatch_claimed_without_agent,
which consults no respawn counter at all, so one side of the round trip
always has fuel regardless of the other's strikes — and even a tripped
main-PM counter only stalls the task silently at blocked instead of
surfacing the oscillation.

- Strikes are counted task-scoped at the unblock() chokepoint
  (agent-agnostic; legitimate needs_revision rework never calls
  unblock, so it structurally cannot trip this), durable in the
  existing orchestration_markers column — no migration.
- Progress between round-trips (commits / revision_count advancing)
  resets the count: real forward motion is not an oscillation.
- On trip: the task is blocked with a HUMAN resolver (the budget-breach
  posture), both dispatchers stop respawning onto it, further unblock()
  refuses until an admin override clears the marker, and the CEO
  notification names both agents and the cycle count.
- _notification_has_live_work now treats a HITL-blocked related task as
  no live work, closing the same loop for the admin-route escalation
  path.

* fix(orchestrator): wire the oscillation trip to the dispatchers and make recovery reachable

- TaskResponse serializes blocker_resolver_type: the dispatchers' HITL-blocked
  skip and the notification-path live-work check now actually fire over the
  wire instead of only against in-process rows.
- The oscillation marker clears on every human transition out of BLOCKED
  (snapshot or not), and the human unblock route treats a tripped task as
  the requested intervention: clears the marker and proceeds, while the
  agent gateway verb keeps refusing.
- The progress fingerprint includes the terminal-children count, so a
  coordination root whose children advanced between escalations resets
  instead of accruing toward a false trip.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 17:20:20 +02:00
23ae0ca217 fix(gateway): covers_parent_criteria hint that teaches the shape; CEO pause/resume (#686)
* fix(gateway): teach the delegate remediate + PM prompt the covers_parent_criteria shape; allow CEO through the plain pause route

- A child draft rejected for missing covers_parent_criteria now gets a
  copy-pasteable corrected skeleton with the parent's real criteria
  inlined, and the PM delegation guidance shows the field as part of
  every child draft — a PM no longer loops on a rejection that named
  the field but never showed the shape.
- The plain pause route now authorizes the CEO tier like its sibling
  lifecycle routes; agent-side pause restrictions are unchanged.

* fix(gateway): delegate-coverage hint heals and degrades on legacy parents

- The coverage-reject path self-heals a criteria-bearing parent whose
  ids are empty or out of length before rendering the hint, so the
  skeleton always shows real references; the renderer itself also
  falls back to quoted criterion texts for any criterion without an
  id instead of emitting a placeholder or truncating the listing.
- The remediate names both legal reference forms (id or exact text)
  again.
- Route comments state the pause/resume check as deliberately
  CEO-only instead of claiming a precedent whose role set is wider.

* test(gateway): real TaskTable rows in the remediation hint round-trips

mypy over tests/ rejects a SimpleNamespace where unknown_ac_refs takes a
TaskTable; instantiating the ORM row directly needs no session and types
cleanly.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 17:10:20 +02:00
4b2546ae19 fix(findings): path-shaped file refs + per-round collapsible findings (#687)
* fix(findings): enforce path-shaped file refs; group panel findings by round

- The findings chokepoint rejects a file that is not a repo-relative
  path shape (prose like a PR reference validated before, and the panel
  then rendered a doomed file-content fetch for it) — narrative belongs
  in evidence, the remediate says so.
- The task-detail Findings tab groups findings into per-round
  collapsible sections (newest expanded) and only attempts a code
  snippet for a path-shaped file ref, so historical prose refs render
  as plain metadata instead of a broken loader.

* fix(findings): admit client-repo path conventions; teach the file-less option

- The shape gate reviews arbitrary client projects, not just this repo:
  plus and at-sign join the character class so SvelteKit route files,
  @types dirs, and @2x assets stay citable. Spaces stay excluded — they
  are the prose signal.
- The file-rejection remediate names the file-less option for
  cross-cutting findings.
- The client mirror notes its deliberate non-ASCII divergence from the
  server gate (unicode server-pass renders snippetless, fail-open).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 17:09:49 +02:00
21910d75ea chore(board): revive dormant board wiring — research key, pitch flow, auditor playbooks (#684)
* chore(compose): pass research key/provider + provisioning token/org through to the orchestrator

ROBOCO_RESEARCH_API_KEY / ROBOCO_RESEARCH_PROVIDER and ROBOCO_PROVISIONING_TOKEN /
ROBOCO_PROVISIONING_ORG were absent from every compose environment stanza, so .env
values never reached the container: research silently ran on the NullProvider
(empty results forever) and any approved pitch died on ProvisioningDisabledError.
.env.example also falsely claimed the provisioning creds are panel-managed.

* feat(board): pitch CEO notification + auditor playbook-draft surfacing

A proposed pitch now nudges the CEO (APPROVAL notification + Telegram link to the
Pitches tab, best-effort — a send failure never fails the verb). auditor_triage
surfaces the oldest pending playbook draft once anomalies are clear — the curation
verbs were granted but nothing ever pointed the Auditor at the review queue; the
scheduled audit prompt names the discovery path.

* docs(prompts): pitch doctrine section + auditor reply-only-dm drift fix

board.md never mentioned the pitch verb, so no board agent ever had a reason to
call it — it gets a dedicated section mirroring the roadmap/spotlight ones, plus
a roadmap-exploration escape hatch (needs-its-own-repo ideas pitch instead).
product-owner.md gains its missing propose_roadmap + pitch entries. The flat
'Auditor has no dm' claims are corrected to the real grant: never initiates,
reply-only in a CEO-opened thread. Doctrine guarded by a prompt-content test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 14:41:14 +02:00
71f5426e40 fix(git): never discard committed local work in rebase_onto_base (#683)
The shared rebase primitive (dev sync_branch verb, PM/CEO rebase path,
submit-freshen, merge-conflict resolver) opened with fetch -> checkout ->
unconditional reset --hard origin/<head_branch>. The dirty-tree gate
protects uncommitted edits only; the reset silently rewound past every
committed-but-unpushed commit — and the commit do-verb never pushes, so
mid-rework a dev routinely has exactly that. The force-with-lease push
then republished the truncated branch as authoritative (the lease
matched the freshly-fetched, never-moved origin ref).

rebase_onto_base now classifies local vs origin/<head> post-fetch:
- behind/equal: reset --hard origin as before (origin loses nothing)
- strictly ahead: reset skipped — the rebase runs from the local tip and
  the lease'd push publishes the previously-doomed commits
- diverged: a patch-equivalence probe (rev-list --right-only
  --cherry-pick) first rescues the self-inflicted residue of a prior
  rebase whose force-push failed (treated as ahead, self-heals on
  retry); only genuine two-sided divergence returns a new
  {status: diverged, local_only, origin_only} — no reset, no rebase,
  no push, neither side silently discarded
- an absent local ref is recovered from origin (branch + checkout,
  never reset)

Callers: the sync_branch verb maps diverged to an actionable envelope
steering to i_am_blocked (stash-preserved note included); the
submit-freshen hard-rejects it like conflicts; the merge-conflict
resolver already escalates any non-rebased/superseded status to the
CEO and degrades gracefully (pinned by test, no code change).

New real-git suite (bare origin + clone, no subprocess mocking)
asserts origin-side outcomes: ahead-publishes, behind-adopts,
diverged-refuses-untouched, absent-ref recovery, superseded,
conflicts, and wedge self-heal on retry via a rejecting pre-receive
hook.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 14:34:25 +02:00
3516d925fe fix(tasks): reconcile acceptance_criteria_ids at the update chokepoint (#682)
Every post-create rewrite of acceptance_criteria (task PATCH route,
prompter update_live_draft / _patch_batch_child / update_live_batch)
routes through TaskService.update()'s generic field loop, which
overwrote the criteria without touching acceptance_criteria_ids —
leaving ids mismatched or empty, and an empty id list silently
disabled the parent-coverage gate entirely.

- New pure _reconcile_ac_ids: one id per new criterion; text-unchanged
  criteria keep their id (children and findings reference criteria by
  id or exact text — a blanket re-mint would orphan every live
  reference), new/reworded text mints fresh, dropped criteria drop
  theirs. create() now stamps through the same helper (explicitly
  supplied ids still win).
- update() derives acceptance_criteria_ids whenever acceptance_criteria
  is rewritten without an explicit id list.
- The parent-coverage gate self-heals a criteria-bearing row whose ids
  are empty/out-of-length (re-stamp in place) instead of returning
  early and silently waiving coverage for the whole subtree.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 14:18:58 +02:00
c4ba351ae0 fix(sequencing): reachability-aware claim bar + sequence_held surfacing (#681)
Three coupled claim-path bugs from the 2026-07-24 live incident, fixed at
the shared root:

- The edge-agnostic sequence bar phantom-held a task behind an unrelated,
  never-connected same-parent sibling that coincidentally shared a lower
  raw sequence (stamp_wave_sequence stamps from a partial per-task view).
  _claim_blocked_by_sequence now branches on is_batch_root_subtask: a
  MegaTask root-subtask (globally-computed Kahn wave, a deliberate
  staged-release barrier) keeps the strict rule unchanged; every other
  same-parent context routes through the pure sequence_blocker_id, which
  only blocks on a real transitive predecessor via dependency_ids UNIONED
  with completed_dependency_ids. A task with no same-parent dependency
  edge at all falls back to the raw bar unchanged (#452 preserved).
- The hold surfaced as claim()'s bare None and was misdiagnosed by the
  verb runner as a concurrent-transition invalid_state. New
  sequence_hold_reason + a proactive _sequencing_claim_guard return a
  dedicated Envelope.sequence_held naming the blocker, on both the
  PENDING and NEEDS_REVISION reclaim paths.
- give_me_work offered tasks the claim gate then rejected: both offer
  paths (list_pending_for_agent, _drop_dependency_held) now consult the
  bar via the exact claim predicate (is_pending_claim_blocked, extended
  to NEEDS_REVISION).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 13:53:29 +02:00
a3f84f3165 chore(routing): retire haiku from delivery-lifecycle roles (#680)
Haiku can't reliably emit the structured envelopes the lifecycle now
runs on — pass_review's per-AC criteria_verified, delegate's
covers_parent_criteria, the findings ledger. A haiku QA/PM claims, gets
validation-rejected, idles, respawns, and loops without advancing a task
(2026-07-24 live: fe-qa on haiku looped four awaiting_qa tasks to zero
progress). The per-token savings (~2x under the Sonnet-5 promo, 3x after)
are dwarfed by the cost of a review that never completes.

Three coordinated changes: ROLE_MODEL_MAP's qa/documenter defaults move
haiku -> sonnet (the actual source of the live incident); the cost_tiered
developer:low -> haiku seed retires to empty (the floor would upgrade it
anyway); and a structured-verb capability floor upgrades any below-floor
Anthropic assignment to sonnet at resolution — from a pin, a ROLE row,
or a future map edit — in both the assignment and legacy paths.
Non-Anthropic providers are untouched (an Anthropic-tier floor, not a
provider policy). pr_reviewer/auditor stay on opus.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-24 12:14:13 +02:00
f8b4a6755c fix(pr-review): fleet PRs are ours by branch ownership, not author identity (#668)
With a GitHub App bound, fleet PRs are authored by <app-slug>[bot] whose
author_association is NONE — the inbound classifier's author heuristics
read that as an outsider and ingested the org's own dev-stream PR as
external_pr for adversarial review (2026-07-23 live: PR #667). The
repo-owner author check only ever covered the PAT era.

_ingest_pr_if_reviewable now skips any same-repo PR whose head branch an
active task owns BEFORE the author-based classification, and
active_task_owns_branch widens from the single polled project to every
project sharing its git_url (the poll collapses a monorepo's
cell-projects to one canonical project, so a sibling cell's ownership
must count — the same sibling scope external_review_task_exists already
uses, now shared via _repo_sibling_project_ids). A deleted-fork head
(GitHub sends head.repo=null) now classifies as fork, failing closed to
review instead of risking a silent ownership skip on a branch-name
collision. Residual, documented: an org PR whose task went terminal with
the PR left open falls through to the author heuristics.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 21:54:45 +02:00
a036c97985 fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking (#666)
* fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking

Three root causes behind the Mini App/bot showing wrong numbers:

Pricing: glm-5.2 gets a grounded per-token rate (z.ai published pricing,
$1.40/$4.40/$0.26 per 1M, source+date in the table comment) so a GLM
fleet day stops reporting $0.00 for half a million tokens; ungrounded
Ollama-Cloud models render "subscription (untracked)" instead of a bare
zero (is_ollama_cloud_model, consumed directly by the cockpit). Side
effect, intended and documented: honestly-priced GLM now trips the
downgrade-only comparator for new qa/documenter complexity pins.

Display timezone: the cockpit bucketed days in UTC for a GMT+2 operator.
New pure foundation module display_time (resolve_zone/local_date/
trailing_dates/day_bounds_utc, DST-correct with tests for the 23h/25h
days) + ROBOCO_DISPLAY_TIMEZONE (IANA-validated, default UTC); the
cockpit's spend/velocity series bucket raw session/completion rows by
the display zone. The UTC-keyed rollup table and the main dashboard are
deliberately untouched.

Agent activity: AgentTable.status was never set to ACTIVE and
current_task_id was never written anywhere — "active: 0, working: []"
was structurally permanent. Every claim path now marks the claimant
ACTIVE with rollback symmetry (_finalize_claim for dev/PM claims,
_qa_or_doc_claim for QA/doc/PR-gate claims, pr_review_claim for external
review) and every release path clears it (pass/fail QA, pr_pass/pr_fail,
complete_review, advance-to-PM-review, reaper unclaim, voluntary
unclaim, reassign retarget, pool divert, admin transitions, unblock
restore-to-in-progress). The bot's /status shares the cockpit's fleet
derivation so the two surfaces can't disagree. Known ceiling, commented:
one current_task_id column shows a multi-root coordinator PM's most
recent claim only.

Drill: sonnet develop -> sonnet adversarial (refuted the original
chokepoint coverage claim; QA/doc/reviewer paths were unwired) ->
correction round (wired them all + restored a dropped assertion, deleted
a dead helper and the dead subscription_billed field) -> review.

* fix(db): post_update on AgentTable.current_task breaks the flush cycle

agents.current_task_id and tasks.assigned_to reference each other, so a
flush touching both rows — every claim now marks its agent ACTIVE — is
an instance-level circular dependency SQLAlchemy cannot topologically
sort. The e2e smoke's full verb paths (12 tests) hit it; the unit and
integration suites never flush both dirty rows with relationships
loaded. post_update emits the FK as a second UPDATE, the canonical fix
for mutually-referencing rows.

* fix(budgets): enforce only explicitly-set budgets — no per-TaskType defaults

The per-TaskType default cap table blocked an unbudgeted coordination
root one opus planning turn in ($1.50 PLANNING default vs. real
coordination spend) — a false positive by design the moment the fleet
runs a priced model. Budgets are now explicit-input only:
effective_task_budget_usd returns None for an unset budget_usd, the
budget sweep skips enforcement (and never prices spend) on None, and
the unblock re-check passes on None so clearing the budget field is
itself a valid resolution. The project monthly cap stays as the
explicit-input fleet-wide backstop. Panel copy tells the truth
("No cap" placeholder; empty = uncapped), and the TaskType default
table plus its resolver are deleted.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 21:09:50 +02:00
0c1d450a05 fix(intake): emit the complete reply when no text deltas streamed (#665)
The intake/secretary chat driver treated StreamEvent text deltas as the
ONLY text channel: AssistantMessage TextBlocks were always swallowed as
already-streamed (the double-render guard). The CLI's partial-message
emission turned out to be remotely gated — on 2026-07-23 the NAS
containers got zero stream_event lines from the identical binary,
flags, SDK, model, and settings that stream fine elsewhere — so the
guard became a total blackout: replies were generated, the relay
carried only init/status/turn_end, and the CEO saw nothing.

SdkIntakeSession.send now tracks whether any text delta arrived during
the turn; normalize() emits an AssistantMessage's complete text only
when none did. Streaming mode is byte-identical (deltas render live,
completes stay suppressed); gated mode delivers the reply as one block
instead of nothing. Covers Secretary (same machinery). Regression
tests: fallback emission, default suppression, and both modes
end-to-end through the session layer.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 18:22:18 +02:00
Renzo FandGitHub d4b7e1e7b8 fix: post-finale completeness sweep — routing surface, provider config, budgets, compose env, interactive exemption (#661) 2026-07-23 09:41:27 +02:00
21d6730400 feat(providers): Gemini CLI provider — ModelProvider.GEMINI (#660)
* feat(providers): Gemini CLI provider — ModelProvider.GEMINI

Mirrors the grok blueprint with source-verified divergences (all facts
pinned against google-gemini/gemini-cli @ 9681621c): no refresher
daemon — Google's refresh tokens are reusable, so the RO host mount is
COPIED to a writable container-local ~/.gemini and each container
refreshes in-process independently (the write-back crash risk on RO
never triggers); settings.json renders security.auth.selectedType
'oauth-personal', experimental.enableAgents=false (subagent ban),
autoConfigureMemory=false with a bounded heap; tool scoping rides the
tiered TOML Policy Engine (deny-only rules that yolo mode structurally
cannot beat); gemini -p with --output-format stream-json; usage parsed
from the run's own stdout stats — the adversarial pass caught the
parser reading the json-mode nested shape while the entrypoint runs
stream-json's FLAT shape (every real run would have priced $0 forever,
hidden by fixtures sharing the assumption) — now flat-primary with the
nested shape as cited fallback; rate-limit classified from structured
error.type only (model-echo immune), native exit 41 auth passthrough;
per-model pricing for the three GA models; migrations 084 (enum) + 085
(seed) complete the 082-085 finale chain. V1 excludes interactive
intake/secretary. Stack-merge required two behavior-preserving
complexity refactors in the shared park/usage plumbing (a park-pair
loop; a usage-reader dispatch dict).

* fix(providers): route gemini usage read through the containment barrier

Mirrors the codex/grok fix — _gemini_usage_json now delegates to
_read_usage_json_contained, so CodeQL's path-injection alert on the
gemini read is resolved by the same resolve-and-contain guard.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 03:53:21 +02:00
c70ff3cf9a feat(providers): Codex CLI provider — OpenAI via ModelProvider.OPENAI (#659)
* feat(providers): Codex CLI provider — OpenAI via ModelProvider.OPENAI

Mirrors the grok blueprint end to end: CodexCliProvider (RO ~/.codex
mount, ANTHROPIC_* blanked), an orchestrator-side codex_auth.py
refresher (JWT-exp staleness, atomic rewrite, lock-serialized single-use
rotation, --check backstop; the CLI's own in-process refresh write
no-ops on the RO mount by design — margins keep the orchestrator ahead
of the CLI's 5-minute window), config.toml rendering with required=true
gateway MCP servers, execpolicy deny rules (forbidden-only), per-role
--sandbox (developer=workspace-write, review/doc roles read-only),
codex exec --json with pinned ROBOCO_CODEX_CLI_MODEL (gpt-5.3-codex),
usage summed from typed turn.completed events priced via the real
4-bucket split, dedicated image + entrypoint, registry/park/finalize/
compose/release wiring. V1 excludes interactive intake/secretary.

Per adversarial review: migration 083 seeds the openai provider row
enabled=True (without it every routing path 404'd — the whole feature
was operationally dead code; grok needed the same seed in 039), the
panel picker gained the OpenAI catalog group it silently lacked, and
exit classification is structural — only stderr and error.message
fields from error events are sniffed (word-boundaried patterns, exact
auth phrases, bare 'login' dropped), so the model echoing on-topic
words can never false-park the provider fleet-wide, proven by a
benign-transcript test. Known open risk flagged, not claimed: whether
codex's workspace-write OS sandbox excludes /app is unverified, and no
hook mechanism exists to port the bash-guard defense-in-depth.

* fix(providers): containment barrier on usage.json reads (code scanning)

CodeQL flagged the codex usage read as path injection — correctly:
os.path.basename does not neutralize '..', and the upstream segment
validator isn't in CodeQL's taint model. The grok/codex reads collapse
into one _read_usage_json_contained helper that resolves the built path
and refuses anything outside the resolved usage root — a hostile id can
never escape regardless of upstream drift. Traversal + containment
regression tests added; a stray noqa in the test file replaced with a
named constant per repo rule.

* fix(providers): use realpath+startswith containment CodeQL recognizes

The is_relative_to() guard was a real barrier but not in CodeQL's
py/path-injection sanitizer model, so the alert persisted. Switch to
the canonical os.path.realpath + startswith(root + os.sep) form, which
CodeQL recognizes as a path-traversal barrier; behavior is identical
(refuse any candidate resolving outside the usage root).

* fix(providers): regexp-allowlist the usage-id segment (CodeQL barrier)

Neither is_relative_to nor realpath+startswith was recognized by
CodeQL's py/path-injection sanitizer model across the str->Path->open
flow. Sanitize the tainted component at the source instead: the id must
fullmatch a strict slug token ([A-Za-z0-9][A-Za-z0-9._-]*, no
separators, no '..'), which CodeQL recognizes as a path-injection
barrier; the realpath+startswith containment stays as defense-in-depth.

* fix(providers): standalone regexp guard so CodeQL recognizes the barrier

The sanitizer was one disjunct of a compound 'or' condition, which
CodeQL's guard analysis does not trace as a barrier. Split the regexp
fullmatch into its own single-condition guard (the redundant '..' check
is dropped — the required alphanumeric first char already excludes it).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 03:20:29 +02:00
165892dc62 feat(routing): cost-tiered complexity routing + saved presets (#656)
The 08-31 lever: model_assignments gains one compound rung —
AGENT_SLUG > ROLE('{role}:{complexity}') > ROLE > GLOBAL — so a
low-complexity task can route to a cheaper tier while coordinators stay
pinned. Structurally opt-in: zero rows means byte-identical routing
(pinned by a named test across every precedence case), the cost_tiered
apply-mode (seeds developer:low→haiku) is reachable only from the
explicit PM-gated endpoint — verified no startup path can apply it.
Overrides are downgrade-only (input-price comparator), allowlisted to
{developer, qa, documenter} — cell_pm excluded per the org's own
coordinator definition and its documented weak-model incidents — and
validated at write time (disabled/unconfigured provider rejected with
remediation; cross-provider-family overrides warn explicitly).

Per adversarial review: the four mode-switch applies now spare compound
rows exactly like agent pins (the 2026-07-17 unscoped-wipe class, new
victim, same fix extended via one shared wipe helper) with panel cache
invalidation + truthful confirm dialogs; preset apply validates the
entire payload BEFORE the wipe (validate-all-first), with a savepoint
crash test proving rollback.

Presets (CEO request): routing_presets table (migration 082) snapshots
the full mix — mode, per-agent overrides, complexity rows — with
save/apply/delete endpoints and a panel preset bar; applying skips
since-removed models with per-entry notes, never silently.

Task complexity threads task_id through _resolve_agent_route at both
call sites; taskless spawns unchanged. 235 backend + 23 panel tests.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 03:04:18 +02:00
10f039c36f feat(eval): golden-task eval harness + doctrine cohort stamp (#655)
* fix(notifications): exponential backoff + CAS claim for expired-unacked re-escalation

The sweep re-escalated every expired unacked ack-required notification
on every ~60s tick, forever — the live incident: 3 fresh blocker
escalations + Telegram DMs per minute from a static stale pile. Now
each notification carries reescalation_count / last_reescalated_at /
reescalation_delivered_count (migration 079): first fire at expiry,
then doubling intervals from 1h capped at 24h, hard stop after
ROBOCO_NOTIFICATION_MAX_REESCALATIONS (default 5) with one permanent
log carrying attempts-vs-delivered so 'seen and ignored' is
distinguishable from 'route never worked'. The due/wait/capped decision
is a pure function in foundation/policy/communications.py.

Per adversarial review, the attempt slot is claimed by compare-and-set
(UPDATE ... WHERE reescalation_count = :n) BEFORE delivery — the
previous draft leaned on the 60s dedup window, which never engages for
BLOCKER_ESCALATION (_LOOP_PRONE_TYPES excludes it), so concurrent
sweeps would have double-delivered. A lost claim skips delivery
outright. Legacy rows read as count=0 and keep today's first-fire
semantics. 61 tests incl. a two-session CAS race and a real alembic
upgrade/downgrade round trip.

* feat(budgets): per-task and per-project cost budgets (flag-gated)

tasks.budget_usd + projects.monthly_budget_usd (migration 080, chained
on 079; adds ix_agent_spawn_sessions_task_id since both enforcement
seams filter on bare task_id). Behind ROBOCO_TASK_BUDGETS_ENABLED
(default off, feature-flags card) — verifiably inert when off.

Claim-time: a project-month-spend guard applies to WORK-STARTING claims
only (i_will_work_on / i_will_plan) — per adversarial review, review/
doc/gate/inbound-PR claims are exempt so in-flight work can always
finish reviewing and merging at cap. Spend counts closed sessions'
estimated_cost_usd PLUS open sessions priced live from token snapshots
(the original closed-only sum read parallel long sessions as $0).

Sweep-side: the existing budget sweep also prices the active task's
spend vs budget_usd (TaskType defaults when null); on breach the task
is BLOCKED (HUMAN resolver, budget marker) BEFORE the graceful stop so
the unclaim no-ops and the dispatcher never respawns onto it, and the
CEO notification names both recovery steps. unblock on a budget-blocked
task re-checks live spend and refuses while still over — no silent
re-breach loop. Panel: budget inputs in both dialogs (0 rejected — a
zero budget silently blocks everything), spend logic consolidated in
TaskService.task_spend_usd. 42 new tests incl. a real-DB spend-query
suite and a two-tick non-refire sweep test.

* feat(eval): golden-task eval harness + doctrine cohort stamp

roboco/eval: 6 BenchTaskSpec fixtures run through the real lifecycle in
a disposable environment (the e2e_smoke harness's fake GitHub + local
git origin + throwaway DB catalog — real isolation, not convention),
scored deterministically (terminal status, revision_count, cycle time,
tokens/cost via the agent_spawn_sessions task_id join) plus a local-
model judge whose output is nested under a non_deterministic-marked
object so cohort diffs don't read judge noise as regression. CLI:
python -m roboco.eval run --role <slug> --cohort <name>. Source-
checkout-only by declared posture (deptry-scoped ignore + a hard
ImportError guard naming why; tests/ never ships in images or wheels).

agent_spawn_sessions.doctrine_version (migration 081, chained on 080)
is stamped at spawn-session finalize from the composed prompt layers —
with the session's model column it identifies a cohort durably.

Per adversarial review: bench runs patch the vault flags off (they were
writing real markdown into the operator's vault), and the real-spawn
OrchestratorStageSpawner is deliberately cut to NotImplementedError —
spawned containers' MCP wiring resolves to the production orchestrator
under real agent UUIDs, so real spawns wait for a dedicated follow-up;
the injectable scripted spawner is the working path. Full suite 13852
passed / 94% coverage in the source worktree; deptry/mypy/xenon clean.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:06:50 +02:00
7c8453e210 feat(budgets): per-task and per-project cost budgets (flag-gated) (#654)
* fix(notifications): exponential backoff + CAS claim for expired-unacked re-escalation

The sweep re-escalated every expired unacked ack-required notification
on every ~60s tick, forever — the live incident: 3 fresh blocker
escalations + Telegram DMs per minute from a static stale pile. Now
each notification carries reescalation_count / last_reescalated_at /
reescalation_delivered_count (migration 079): first fire at expiry,
then doubling intervals from 1h capped at 24h, hard stop after
ROBOCO_NOTIFICATION_MAX_REESCALATIONS (default 5) with one permanent
log carrying attempts-vs-delivered so 'seen and ignored' is
distinguishable from 'route never worked'. The due/wait/capped decision
is a pure function in foundation/policy/communications.py.

Per adversarial review, the attempt slot is claimed by compare-and-set
(UPDATE ... WHERE reescalation_count = :n) BEFORE delivery — the
previous draft leaned on the 60s dedup window, which never engages for
BLOCKER_ESCALATION (_LOOP_PRONE_TYPES excludes it), so concurrent
sweeps would have double-delivered. A lost claim skips delivery
outright. Legacy rows read as count=0 and keep today's first-fire
semantics. 61 tests incl. a two-session CAS race and a real alembic
upgrade/downgrade round trip.

* feat(budgets): per-task and per-project cost budgets (flag-gated)

tasks.budget_usd + projects.monthly_budget_usd (migration 080, chained
on 079; adds ix_agent_spawn_sessions_task_id since both enforcement
seams filter on bare task_id). Behind ROBOCO_TASK_BUDGETS_ENABLED
(default off, feature-flags card) — verifiably inert when off.

Claim-time: a project-month-spend guard applies to WORK-STARTING claims
only (i_will_work_on / i_will_plan) — per adversarial review, review/
doc/gate/inbound-PR claims are exempt so in-flight work can always
finish reviewing and merging at cap. Spend counts closed sessions'
estimated_cost_usd PLUS open sessions priced live from token snapshots
(the original closed-only sum read parallel long sessions as $0).

Sweep-side: the existing budget sweep also prices the active task's
spend vs budget_usd (TaskType defaults when null); on breach the task
is BLOCKED (HUMAN resolver, budget marker) BEFORE the graceful stop so
the unclaim no-ops and the dispatcher never respawns onto it, and the
CEO notification names both recovery steps. unblock on a budget-blocked
task re-checks live spend and refuses while still over — no silent
re-breach loop. Panel: budget inputs in both dialogs (0 rejected — a
zero budget silently blocks everything), spend logic consolidated in
TaskService.task_spend_usd. 42 new tests incl. a real-DB spend-query
suite and a two-tick non-refire sweep test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:06:14 +02:00
1d5a8e846f fix(notifications): exponential backoff + CAS claim for expired-unacked re-escalation (#652)
The sweep re-escalated every expired unacked ack-required notification
on every ~60s tick, forever — the live incident: 3 fresh blocker
escalations + Telegram DMs per minute from a static stale pile. Now
each notification carries reescalation_count / last_reescalated_at /
reescalation_delivered_count (migration 079): first fire at expiry,
then doubling intervals from 1h capped at 24h, hard stop after
ROBOCO_NOTIFICATION_MAX_REESCALATIONS (default 5) with one permanent
log carrying attempts-vs-delivered so 'seen and ignored' is
distinguishable from 'route never worked'. The due/wait/capped decision
is a pure function in foundation/policy/communications.py.

Per adversarial review, the attempt slot is claimed by compare-and-set
(UPDATE ... WHERE reescalation_count = :n) BEFORE delivery — the
previous draft leaned on the 60s dedup window, which never engages for
BLOCKER_ESCALATION (_LOOP_PRONE_TYPES excludes it), so concurrent
sweeps would have double-delivered. A lost claim skips delivery
outright. Legacy rows read as count=0 and keep today's first-fire
semantics. 61 tests incl. a two-session CAS race and a real alembic
upgrade/downgrade round trip.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:05:41 +02:00
fa459998b4 feat(git): env-ladder rung protection at the shared remote-delete chokepoint (#651)
Rung protection lived only in delete_task_branch; the post-merge PR-
source cleanup (and the stale-branch sweep's shared primitive) could
still delete a branch that IS a ladder rung. _protected_branches_for_
deletion(slug) — field ∪ rung names, null-ladder shim included — now
feeds _delete_remote_branch_best_effort, so every remote deletion path
is covered; delete_task_branch's local rung check is removed as exactly
subsumed (verified byte-identical comparison semantics). Bonus closed
gap: a renamed trunk (default_branch 'trunk', null ladder) is now
delete-protected, which the hardcoded main/master floor never covered.

Per adversarial review, the deletion lookup fails CLOSED: a raised
project lookup skips the delete with a warning (a skipped best-effort
delete just retries next sweep — free safety), while a genuinely-gone
project proceeds with the hardcoded floor (its ladder is meaningless).
The rebase/sync resolver stays fail-open — a refused rebase on a DB
blip would wrongly block work, a different tradeoff, now documented.
Panel tooltip updated to the new truth. 29 tests.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:05:33 +02:00
3806317aa7 fix(guard): operator-scoped XFF hop peers + tailnet allowlist (live-incident fix) (#650)
Two coupled hardenings from the chain-peers adversarial rounds plus the
root-cause fix for the live post-deploy incident where the CEO was
blocked from the panel ('IP not allowed: 100.x.x.x').

Hop peel-set: the whole docker bridge pool leaves the XFF hop set — hops
are now loopback plus operator-named single addresses only
(ROBOCO_GUARD_TRUSTED_CHAIN_PEERS, plain IPs; CIDR entries rejected with
a warning because a range readmits sibling containers). Default-empty
closes the CGNAT-forge residual outright; a gateway-fronted Tailscale
Serve deploy sets its real gateway IP, and a rate-limited detection log
names exactly that IP when an unconfigured host-proxied tailnet chain is
seen, so the silent-regression shape is observable. The connecting-peer
gate (may nginx present XFF at all) deliberately keeps the broad bridge
pool — different check, unchanged.

Incident root cause: guard-core's whitelist is an EXCLUSIVE allowlist
(any non-member is refused), so honestly resolving the tailnet client IP
made ip_security reject the CEO. The tailnet CGNAT range joins
_guard_whitelist() deliberately: Tailscale authenticates device
membership before a packet arrives, real-IP stamping still buys correct
attribution, and any future non-tailnet exposure keeps full scrutiny.
Both compose files now pass ROBOCO_GUARD_EMERGENCY_WHITELIST through to
the orchestrator (the operator escape hatch previously did nothing in a
compose deploy).

NAS is running ROBOCO_GUARD_PASSIVE_MODE=true as interim mitigation —
flip back to false when this deploys. 66 tests.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:05:29 +02:00
da4d9b333d feat(git): protected-branches enforcement + panel editor (#649)
projects.protected_branches existed end-to-end but nothing consulted it
— the panel had no editor and the git safety checks used hardcoded sets.
Now: GitService._protected_branches_for(slug) (frozenset, stripped,
fail-open to the hardcoded floor with a warning log) is unioned — never
replacing, only tightening — into rebase()'s refusal set, the shared
_delete_remote_branch_best_effort skip set (threaded through every
caller: task cleanup, PR merge/close cleanup), and sync_task_branch,
which now refuses to force-push a protected-named head (the dev-facing
sync_branch verb path the HTTP-only fix would have missed). Matching is
exact and case-sensitive; an empty list degrades to exactly the old
hardcoded behavior, pinned by union-floor regression tests (master/main
stay refused regardless of the project list).

Panel: chips editor for the field in the edit-project dialog (add via
Enter/comma, paste-splitting on comma-separated lists, dedup, clear-to-
empty persists []) with an honest tooltip scoped to what is actually
enforced. Tests cover both the incumbent GitHub-App dialog suite and the
new Protected Branches suite in one harness.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 20:30:05 +02:00
4585a248ce feat(x): redraft loop on CEO reject — feedback re-enters the draft flow (#648)
* feat(x): redraft loop on CEO reject — feedback re-enters the draft flow

A rejected X draft's reason used to die with the cancel. reject() with a
non-blank reason now schedules a redraft after its commit
(defer_after_commit; fresh session; never blocks or fails the HTTP
response): XEngine.redraft_from_rejection re-drafts the same source kind
via the local model with the reason and rejected body folded in as
revision guidance, originating ONE fresh held draft — mirroring the
video pipeline's reauthor_from_rejection. Local-model failure or empty
output originates nothing (no degraded copies); markers carry forward
whole so a redrafted reply/spotlight stays fully functional downstream;
bodies ride the same 280 clamp; the open-posts cap holds.

Hardened per adversarial review: reject() is now idempotent on an
already-CANCELLED target at both check sites (mirroring approve's
already_rejected guard — a replayed reject schedules nothing), and the
dedup check+originate runs under a non-blocking identity-keyed Redis
lock (SET NX + compare-and-del, matching the approve/reject mutex
style) so racing rejects can't stack duplicate drafts — lock held or
Redis down skips the redraft, which is always safe. Tests pin the
fresh-session contract by session identity, the replay no-op, the
lock-skip, and clean up their own committed rows.

* fix(tests): runtime UUID import + typed task-id coercion in x cleanup helper

CI's quality gate runs mypy over tests/ (the local pass covered only
roboco/): the _delete_tasks calls handed ORM-typed ids where uuid.UUID
was expected. Coercing at the call sites then exposed that UUID was
imported under TYPE_CHECKING only — a runtime NameError. Import moved
to runtime; both call sites coerce explicitly.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 20:30:01 +02:00
583d43f213 fix(guard): resolve the tailnet client behind host-proxy hops for the WAF (#646)
fastapi-guard peels a fixed trusted_proxy_depth=1 from X-Forwarded-For
(the rightmost entry, which nginx itself recorded). That is correct for
every chain except host-proxied tailnet traffic (Tailscale Serve →
nginx), which arrives as [tailnet-client, loopback-or-bridge-gateway] —
depth-1 resolves it to a whitelisted hop IP, leaving WAF/ban/rate-limit
inert for the whole /tg surface (the documented ceiling).

ClientIpResolutionMiddleware (pure ASGI, wraps SecurityMiddleware so it
runs first) stamps guard_core's request.state.client_ip cache — its
supported pre-resolution seam — for EXACTLY that shape: peel known local
hops (loopback + docker bridge pool) from the right, stamp only when at
least one hop was peeled AND the candidate is in the tailnet CGNAT range
(100.64.0.0/10). Every other shape abstains, so direct LAN clients,
agent containers relaying through nginx (even with forged public-IP
prefixes), and all-hops operator traffic resolve byte-for-byte as
before. Documented residual: a same-bridge container forging a CGNAT
prefix only DE-privileges itself (loses its whitelist exemption). XFF is
read first-occurrence to match Starlette's own header semantics, and a
wiring test pins the middleware mount ORDER, not just presence.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 20:29:54 +02:00
2e889c7009 feat(lifecycle): inherit advanced upstream base on work re-claims (#644)
* feat(lifecycle): inherit advanced upstream base on work re-claims

A re-claim reused a branch cut at an earlier claim, so upstream work
merged since (UX/UI landing on the root after the cell branch was cut)
never reached BE/FE branches — divergence and avoidable conflicts.
_finalize_claim now merges the advanced base into the pre-existing branch
via the dependency-lineage merge: already-ancestor is a no-op, a conflict
aborts at the cut point and leaves a transition note steering the agent
to sync_branch, a clean merge logs an audit trail; never fails the claim.

Double-gated: by role (developer/cell_pm/main_pm — QA/documenter/gate
claims review the branch as pushed and never move it) AND by pre-claim
status (pending/needs_revision only — a PM's i_will_plan re-claim of its
own awaiting_pm_review task must not move a branch that already passed
QA + the PR gate). Fresh cuts already branch from the live remote base.
Cell-PM prompt now orders reading the upstream design docs before
planning.

* fix(lifecycle): extract base-inheritance gate predicate for xenon budget

The four-condition inline gate pushed _finalize_claim to cyclomatic
rank C; the quality gate caps blocks at B. The decision moves to a pure
module-level predicate, byte-for-byte the same logic.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 20:29:46 +02:00
b91229f487 fix(orchestrator): break the notification-driven respawn loop (#643)
The escalation/approval dispatchers spawn a notification's recipient every
cooldown window for as long as it stays pending. These spawns carry no
task_id, so the PM respawn breaker never sees them — a single wedged
alert/escalation whose recipient never resolves it respawns that recipient
forever. Observed live: fe-pm's unacked alerts kept main-pm/fe-pm spawning
every ~2-3 min for 6+ hours.

Two guards, both gating the spawn after the existing cooldown:
- A hard per-(agent, notification) attempt cap (notification_spawn_max_attempts,
  default 5): once a notification has respawned its target that many times
  without being acknowledged, stop and log once. The count is id-scoped and
  survives map pruning (re-stamp), so a fresh escalation is unaffected.
- A live-work check before spawning: skip when the notification has expired,
  is stale past notification_spawn_max_age_seconds (default 6h — wedged or
  reloaded from before a restart), or its related task is already terminal.
  Fail-open — a failed task fetch or unparseable field never suppresses a
  real escalation.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 17:10:55 +02:00
Renn F f74131a122 fix(release): commit the approve() COMPLETED write under the release lock
approve() flushed the published proposal's COMPLETED status but left the
durable commit to the background caller (_run_approve_background), which runs
after approve()'s finally has already released the Redis lock. In that window
a concurrent reject() could acquire the freed lock, re-read a row whose
COMPLETED write was only flushed (invisible to its own session under READ
COMMITTED), pass its guard, and flip the just-published proposal to CANCELLED
— last writer winning the row. The bug-sweep (#638) fixed reject()'s side of
this but left approve()'s, so reject()'s 'fails closed' guarantee didn't hold
end to end.

Commit COMPLETED while still holding the lock (mirroring XPostService._post),
so it's durable before release and a racing reject sees it and refuses. A
cross-session regression test proves a fresh connection sees COMPLETED the
moment approve returns (it read 'pending' before the fix).
2026-07-22 15:53:02 +02:00
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
Renn F a1233b2aeb fix(panel): V6 review gaps — honest errors, safe secretary start, real tests
CEO A2A mutations now invalidate the Mine-list query key so the list
refreshes without the socket; the tg Metrics tab renders explicit error
notes instead of confident zero stats when a section's fetch fails; the
tg Secretary chat checks a new registry-backed /secretary/live/active
route before auto-starting, showing a Take Over button instead of
silently killing a session live on another device; the AI-routing card
surfaces roster fetch errors instead of an empty grid; the shared
acceptance-criteria editor caps at the backend's 7-item limit; the
board-tab comment no longer calls the task sheet read-only. Tests:
task-sheet approve/reject interactions (not just visibility), a
non-demo metrics error-state test, secretary take-over branches, and
dashboard-router auth-gate coverage (the e2e harness now mounts the
dashboard router so the gate is actually exercised).
2026-07-22 03:31:25 +02:00
Renn F 5ca8a9c4a6 fix(github-app): PAT fallback covers every mint failure; video/motion/x cleanups
mint_installation_token now wraps JWT build + HTTP + parsing so raw
httpx/jwt failures surface as GitHubAppError and the existing PAT
fallback catches them all (a GitHub outage no longer crashes git
operations for App-bound projects). The PEM is validated at
credential-set time instead of first mint, and the installation-token
cache is cleared when credentials are deleted. Also: the video
preview root resolves symlinks like its sibling route (frames under a
symlinked workspaces_root no longer false-404), the tracked dangling
motion/node_modules symlink is removed and the gitignore gains a
slash-less entry that actually matches symlinks, changelog caption
input strips GHSA refs like PR refs, and the edit-project dialog hides
the GitHub App section for auto-detected gitlab.com projects and warns
before a save that would clear both auth sources.
2026-07-22 03:31:12 +02:00
Renn F 0296ec6fde fix(settings): ceo_name write-through renames the CEO agent row
The ceo_name setting only reached the panel header — the CEO agent row
kept its seeded name, so agent selectors (create-flag, assignee pickers,
kanban) still showed the old name after a rename. Setting ceo_name now
updates the CEO row's name in the same transaction, resolved via the
shared duplicate-tolerant get_agent_by_role lookup. The seed path never
overwrites existing rows, so a custom name survives restarts. The
agent-facing docs/map entries drop the personal name. Write-through
tests pin ancient created_at on fixture rows so they stay deterministic
against CEO rows other suite tests may have committed.
2026-07-22 03:31:01 +02:00
Renn F 5143aab698 fix(notifications): one duplicate-tolerant role lookup for every singleton-role site
The duplicate-CEO-row fix landed on one call site but three identical
bare 'select(...).where(role == ...)' + scalar_one_or_none() lookups
remained in the orchestrator (auditor + two CEO), each still raising
MultipleResultsFound under the same condition. All five sites now route
through a shared get_agent_by_role helper (earliest-created row wins).
The five event-bus notification handlers also thread task_title so a
revived producer renders titles instead of #id8 (protocol signatures
updated to match the service), the pull_request CI trigger mirrors the
push trigger's paths so a fork PR touching only those paths still runs
CI, and a stale a2a comment about auditor/pr_reviewer lacking read_a2a
is corrected.
2026-07-22 03:30:50 +02:00
Renn F 161b36b563 fix(git): stage only codegen-produced drift and link the auto-commit
The pre-push codegen auto-commit staged with a blanket 'git add -A', so
any pre-existing dirty file in the worktree (e.g. crash-orphaned edits a
resume never cleans) was silently swept into the 'regenerate generated
artifacts' commit. Now the porcelain state is snapshotted before and
after the codegen run and only newly-dirty paths are staged with
'git add --'. The commit is also recorded via _link_commit_to_task with
the pushing agent's id threaded from both push call sites, so it shows
up in the task's commit history like every other commit.
2026-07-22 03:30:40 +02:00
Renn F 07bdef3db2 fix(guard): scope the internal-mesh whitelist to loopback + docker bridge
The WAF/IP-ban/rate-limit exemption whitelisted all of RFC1918, so any
LAN client reaching the host-published nginx port resolved to its real
192.168.x.x and rode the exemption. Only the docker agent mesh needs it:
keep 127.0.0.1/::1 + 172.16.0.0/12, drop 10.0.0.0/8 and 192.168.0.0/16.
New middleware tests drive the real XFF resolution path (forwarded LAN
client blocked, direct bridge peer exempt); the remaining ceiling
(host-proxied traffic resolving to loopback/bridge-gateway under
depth-1 XFF trust) is documented instead of claimed away.
2026-07-22 03:30:32 +02:00
5f42a93b4f feat(git): auto-regenerate + commit codegen drift before push (#632)
A project that checks in generated artifacts (RoboCo's lifecycle renders,
verb tables) drifts whenever their source changes. The agent pre-submit gate
(make gate) omits foundation-check, so drift is invisible at the desk and only
fails on CI's drift gate — a failure with no link back to the task, which made
one live task thrash 8 revision rounds.

New per-project codegen_command (migration 078): run in the task's worktree
right before push, and any drift committed into the same push, so CI never
sees stale artifacts. Fail-open — a broken/timeout codegen command logs and
lets the push proceed (CI's drift gate is the safety net); a null command
(every project without checked-in codegen) is a pure no-op. Hooked at both
push_branch (open_pr's first push, the PR head CI grades) and push_task_branch
(later re-pushes). RoboCo sets codegen_command='make codegen' (a new Makefile
target — the write counterpart to foundation-check's read) via the panel.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-21 16:38:07 +02:00
73c05cfa5e feat(a2a): CEO can DM the Auditor and PR reviewers (#623)
* feat(a2a): CEO can DM the Auditor and PR reviewers

A mid-flight PR reviewer or Auditor that's stuck was unreachable — the CEO
had no way to DM them. Both roles now carry dm/read_a2a, so the CEO can open
a 1:1 and they can reply in-thread through the existing CEO-reply path.

Scoped deliberately: the Auditor stays a silent observer to its peers — it
gains no peer-initiation surface (can_a2a_direct routes it through
_check_auditor_a2a, which refuses every initiation target; it can only reply
inside a CEO-opened DM). PR reviewers keep their owning-PM scope. Intake and
Secretary stay excluded — they have their own dedicated chat pages.

NO_COMMS_ROLES drops to {prompter, secretary}; the panel's EXCLUDE_NON_DM_ROLES
matches. KB/docs updated so the 'auditor/pr_reviewer have no dm' claim isn't
left stale.

* test(a2a): smoke guard checks _NO_COMMS_ROLES, not a hardcoded 'auditor'

The dm() runtime guard no longer names the auditor (it now carries dm to
reply to the CEO); it refuses the canonical _NO_COMMS_ROLES set. Assert on
that set so the smoke test tracks the guard, not a stale role name.

* chore(foundation): regenerate verb tables for auditor/pr_reviewer dm+read_a2a

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-21 05:54:29 +02:00
7b84162ae9 feat(github-app): App credentials, installation tokens, and a Select repo picker (#621)
* feat(github-app): App credentials, installation tokens, and a Select repo picker

RoboCo was 100% PAT-based. A singleton Fernet-encrypted github_app_credentials
row (migration 077, telegram-credentials pattern) now stores the App id +
private key; github_app_auth mints RS256 app JWTs and caches installation
tokens until 5 minutes before expiry. Projects can bind an installation
(projects.github_installation_id): get_decrypted_token returns a minted
installation token for bound projects and falls back to the stored PAT on
any minting failure, so all ten token consumers work unchanged.

CEO-gated routes expose credentials CRUD plus installation/repo listing, and
the New Project dialog gains a Select repo picker (disabled with a HelpTip
until the App is configured) that fills the git URL and binds the
installation; manual URL + PAT stays the default path.

* test(panel): mock the GitHub App credentials card in the settings page test

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-21 00:54:20 +02:00
16fa018ace feat(x): real voice guide, slop ban, and caption craft for drafted content (#615)
* feat(x): real voice guide, slop ban, and caption craft for drafted content

Release posts and mention replies were drafted from a one-sentence voice
stub while the reasoning-backed Head-of-Marketing voice guide only reached
the off-by-default spotlight path. The drafting prompts now carry the full
voice rules, a banned AI-slop list, three style exemplars, hook-first
structure, and an under-240-char budget so the 280 clamp never truncates
mid-sentence.

An empty brand_voice now nudges the CEO exactly once (durable
system_settings marker) instead of silently shipping baseline voice forever.
A failed reply draft skips origination instead of shipping 'Thanks for the
mention!'. Video dev prompts and motion/README gain per-platform caption
templates (X: hook + specifics + outro; TikTok: hook + short lines + few
niche hashtags).

* docs(motion): reflow the new Captions section to satisfy the prose gate

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 20:59:55 +02:00
c8f55be904 fix(notifications): task titles and agent slugs replace raw UUIDs (#616)
* fix(notifications): task titles and agent slugs replace raw UUIDs

Notification producers interpolated raw task/agent UUIDs into subjects and
bodies ('Task 68e1e4db-... unblocked', 'handed back to 00000000-...-0004').
A tiny notification_text helper (task_display: title-first with a #id8
fallback; agent_display: identity-map slug first, DB lookup fallback) now
feeds every producer: all 13 NotificationService methods, the 7
delivery-service bodies whose subjects were already title-based, the
substitute-PM ad-hoc insert, and the orchestrator/choreographer callers,
which thread the task row's title one call deeper. Fixes the literal
'cell_pm' role string sent as an agent slug in the merge-conflict
notification. Tool-call examples like unblock('<uuid>') keep the raw id on
purpose — agents need it.

* test(notifications): board-review subject assertion matches the humanized format

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 20:59:36 +02:00
e125ef08aa feat(settings): CEO display name is configurable, Renzo hardcode removed (#612)
The header chip and the Settings User Info card rendered a literal 'Renzo'.
The name now lives in the system_settings store under ceo_name (validated:
trimmed, non-empty, max 60 chars) with the same client-served default the
transcript-retention card uses, editable inline from the User Info card.
Agent prompts already refer to 'the CEO' generically, so no prompt rewiring;
the two agent-facing RAG docs drop the name too. License/CLA copyright is
untouched.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 20:38:44 +02:00
57b9e76b12 fix(x): release caption uses curated CHANGELOG headlines, not commit subjects (#607)
draft_release_post was fed highlights=list(report.change_summary) — raw
per-commit subjects — so the announcement model parroted the top commit
('RoboCo API v0.26.0 is out: docs: curate the full Unreleased body #601').
New pure changelog_highlights() extracts the bold feature leads from the
curated release entry (report.drafted_changelog), stripping PR refs and
trailing periods; approve() prefers those and falls back to change_summary
only when the changelog yields nothing. The video captions were already
good because the authoring dev read the changelog — same source now.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 10:35:29 +02:00
bf1012e952 fix(guard): exempt the internal agent mesh from WAF + IP-ban (#605)
With the guard active on the NAS, a documenter's journal-entry POST body
tripped a WAF signature and the guard banned its docker-bridge IP
(172.18.0.7) — after which EVERY gateway verb from that agent (dm,
i_am_idle, claim_review) was blocked by ip_security, wedging the agent
into a respawn loop. Confirmed live: roboco:guard:banned_ips:172.18.0.7
in redis with passive=False. The guard's threat-ban targets the external
attack surface arriving via nginx; internal HMAC-authenticated agents
reach the orchestrator DIRECTLY on the docker bridge and must not be
subject to it. build_security_config now sets whitelist to the RFC1918 +
loopback ranges. External traffic keeps its real client IP (XFF, trusted-
proxy depth 1 — un-spoofable into a private range), so the WAF still
fires on genuine attackers; the middleware tests model that with a
public TEST-NET-3 IP.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-20 10:14:03 +02:00
81bd5e722b fix(guard): https enforcement is nginx's layer + calibrate the prose validators (#599)
The 2026-07-19 outage root cause: enforce_https keyed off
environment==production, but nginx is the single entry point — the app
only ever sees proxy-HTTP, and the production NAS terminates no TLS at
all — so the moment the guard went active, https_enforcement blocked
the entire request stream. It is now hardcoded off at the guard-config
level (TLS and http->https redirects belong to nginx, not the app).
Same calibration pass for the two prose validators the flip armed: the
secret-exfil key pattern requires a real b64-shaped value so the
documented placeholder lines can't block, and the injection override
pattern requires the second-person 'your' so neutral engineering prose
about the guard subsystem passes.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 23:28:54 +02:00
fc41dfa40e fix(security): active guard enforcement, CEO A2A target check, notification expiry (#595)
* fix(security): guard goes active; CEO A2A respects no-comms roles; ack notifications expire

ROBOCO_GUARD_PASSIVE_MODE defaults to false in both compose files — the
deferred post-calibration flip; fail_secure stays off and the env override
remains the rollback. can_a2a_direct no longer short-circuits the CEO past
the no-comms set (auditor/pr_reviewer/prompter/secretary), now canonical
in foundation.policy.communications.NO_COMMS_ROLES and shared with the
content-actions gate; the A2A service refuses at conversation creation
instead of silently suppressing the wake. Ack-required notifications get
expires_at stamped from ROBOCO_NOTIFICATION_ACK_TTL_HOURS (default 48,
0 disables), so the re-escalation sweeper's expires_at query matches rows
for the first time.

* refactor(notification): extract _ack_and_expiry — xenon rank back under B

The expires_at stamping pushed _create_notification_with_session to
rank C; the requires_ack + expiry derivation moves into a helper with
the same semantics and comments.

* test(conftest): dispose the global DB engine after every test

Production code reaching get_db_context()/get_engine() lazily creates the
process-global engine bound to the current event loop; with per-test
function-scoped loops, any later test touching the global path inherits a
dead-loop engine and dies with 'Future attached to a different loop' —
the order-dependent class that has been wandering the suite (cloud_auth
login, metrics, tasks-routes, full-lifecycle) whenever collection order
shifts. An autouse fixture now close_db()s after every test, keeping the
global path loop-local; no-op when untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 18:46:44 +02:00
5b27a443e9 chore(agnosticism): close the audit residue — B6/B8/B10 + three MAJORs (#587)
Thread the deployer's product name through the X reply + feature-spotlight
prompts (B6 leftover; release/video paths shipped in #570); make the
docs-site repo/URL config (ROBOCO_DOCS_SITE_*, defaults unchanged) instead
of a roboco-website hardcode (B8); de-assert our repo from the Main PM
prompt (B10); derive PR labels from the real target branch instead of
literal to-master/to-slave; drop the stale headcount from base.md; and
make the bash-guard's Makefile check require an actual quality/gate/lint/
test target before denying raw package-manager commands (no more
false-remediation loop on Go/Rust Makefiles).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 17:52:17 +02:00
29f7082030 fix(git): cancel closes the task's open PR; bulk cleanup spares live dependents (#593)
Task cancellation left the task's PR open on the forge forever: cancel()
now best-effort-closes the recorded PR for the task and its cascaded
descendants (close_task_pr_best_effort resolves owner/repo off git_url —
no clone needed; never raises into the cancel). The bulk stale-branch
sweep gains a dependents guard: a branch still recorded by a non-terminal
task, or serving as a live child's resolve_parent_branch base, is excluded
from the candidate window — mirroring the existing env-ladder-rung skip.
Scoped to the sweep, not delete_task_branch, so the BFS cascade-cancel
can't falsely block a parent's branch on its own about-to-cancel child.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 17:38:30 +02:00