Highlights reorder to marketing order (Added/Changed before Fixed/Security
— the drafting model anchors on highlight #1 and the changelog opens with
Security), the prompt bans verbatim highlight copying and internal plumbing
jargon, and the deterministic fallback becomes a generic announcement that
can never quote a raw bullet.
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.
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).
The push/pull_request paths filters covered docs/panel/motion but not
docs-redirects/**, so a redirect-stub commit landing as the slave tip
produced no CI run and the fail-closed release-readiness gate read
"unknown" — blocking every release proposal until unrelated code landed.
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.
The docs site renamed the page to journals-and-notifications when the
channel/session comms subsystem was removed; the stub still redirected
the old Pages URL to the dead path, landing visitors on a 404.
* feat(panel): promote project settings to a full page
The edit-project dialog carried ~30 fields across 7 concerns in one
flat scroll with a per-tab width swap — outgrown. Project settings now
live at /projects/[id]/settings as a card-per-concern grid (the
settings page's own pattern) with per-card save and Conventions as a
page-level tab at natural width; the list Edit action routes there,
and a slim quick-edit dialog (name/cell/active) replaces the
kitchen-sink.
* chore(panel): one disclosure primitive, DialogFooter everywhere, three dialog widths
collapsible-section moves to ui/ as the single sectioned-disclosure
primitive (task dialogs' raw Collapsible and create-project's ad-hoc
showAdvanced converge onto it); every hand-rolled dialog footer becomes
DialogFooter; dialog widths collapse from ten ad-hoc classes to three
named sizes, with deliberate outliers annotated. No behavioral change.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
The edit-project dialog carried ~30 fields across 7 concerns in one
flat scroll with a per-tab width swap — outgrown. Project settings now
live at /projects/[id]/settings as a card-per-concern grid (the
settings page's own pattern) with per-card save and Conventions as a
page-level tab at natural width; the list Edit action routes there,
and a slim quick-edit dialog (name/cell/active) replaces the
kitchen-sink.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
* [170c9578] Frontend: Panel consistency & UX wave (forms audit, command palette, kanban merge, responsiveness) (#688)
* [f1957610] Stream1-A: Project form sync (#667)
* [f1957610] feat(panel): expose codegen_command in create-project dialog
Add the Codegen Command input to create-project-dialog.tsx, mirroring
the field already present in edit-project-dialog.tsx. All other
fields named in this task (git_provider, github_installation_id,
environments, protected_branches, video_engine_enabled,
monthly_budget_usd with gt=0 client validation, sandbox_extensions)
were already implemented on this branch's base by prior work, and the
ProjectCreate/ProjectUpdate types in types/index.ts already match the
backend ProjectCreateRequest/ProjectUpdateRequest schemas exactly --
no further changes were needed there.
* [f1957610] docs(forms): add project-fields-audit reference for future field consistency
Create a living audit of which project configuration fields are exposed in the create vs. edit dialogs, mapping to the backend ProjectCreateRequest/ProjectUpdateRequest schemas. This serves as a future reference to prevent field-sync gaps and documents the intentional asymmetry (create focuses on git setup, edit adds autonomy/maintenance toggles). Includes a checklist for adding new project fields in the future.
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
* [515697f4] feat(panel): settings save feedback + forms-audit.md living reference (#669)
Add per-toggle confirmation toasts to the four Settings-page prefs
(notifications, sound, auto refresh, refresh interval) so an immediate
write is never indistinguishable from a silent failure. These prefs
stay on the already-shipped client-persisted useUIStore pattern
(CHANGELOG.md "Settings preferences persist as real client prefs
instead of 422-ing as theater") rather than settingsApi, since the
backend _VALIDATORS allowlist deliberately excludes them and the
parent task scoped this stream as needing no backend schema changes.
Check in docs/forms-audit.md: a living form x field x verdict table
covering Stream1-A (project dialogs), Stream1-B (task dialogs), and
this settings work, with a header note that future backend schema
changes require a row update. Fixes the project-slug help text
(now correctly says letters/numbers/hyphens, not just hyphens).
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
* [80a215a4] Stream2-A: Command palette component (#670)
* [80a215a4] feat(panel): Cmd+K command palette component
Radix Dialog + combobox pattern searching tasks/agents/projects/pages,
localStorage recents under roboco-cmd-recents, keyboard nav (arrows/
Enter/Escape), mounted globally in the dashboard layout.
* [80a215a4] fix(panel): restore fields dropped from ui-store.ts by prior merge
Stream1-C's merge stripped notificationsEnabled, soundEnabled,
autoRefresh, refreshIntervalSeconds, a2aContextOpen, quickActionIds,
productsView, and projectsView from the shared UI store, breaking
typecheck for settings/quick-actions/products/projects/a2a/notification
consumers repo-wide. Restored per already-committed tests + consumers.
* [80a215a4] docs(panel): add command palette reference guide
Documents the global Cmd+K search feature: usage (keyboard shortcuts, search categories, recents), architecture (CommandPalette component, useCommandPalette hook, fuzzy-match and recents helpers), data flow, and verification against live API data.
---------
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
* [cdc371d1] Stream4-A: Responsiveness audit and fix — wide-content pages (#671)
* [cdc371d1] fix(panel): bump Button sm size to 36px touch-target floor
Button's size="sm" variant was h-8 (32px), used as literal row-action
buttons on the overview page's CEO Approval/PR Review queues and other
controls across settings/metrics/agents/a2a. Bump to h-9 (36px) to meet
the touch-target floor everywhere at once, keeping the smaller
horizontal padding/gap intact for visual density.
* [cdc371d1] fix(panel): make AlertDialog scroll its body at short viewport heights
AlertDialogContent lacked the max-h-[85vh]/overflow-y-auto that the
sibling DialogContent already has, and AlertDialogFooter lacked
DialogFooter's sticky bottom-0 pinning. A tall description at a short
viewport height (mobile landscape) could clip the action buttons off
screen with no way to reach them. Affects the settings page's
GitHubAppCredentialsCard/FeatureFlagsCard confirm dialogs (and every
other AlertDialog app-wide). Ports DialogContent's already-solved
scroll pattern onto AlertDialogContent/Footer.
* [cdc371d1] fix(panel): wrap Scorecards Members table in ResponsiveTable
metrics/scorecards-tab.tsx's 9-column Members table was a bare
<Table> with no mobile-card fallback, unlike its sibling tables in
the same file (Rework, SpawnWaste) and sessions-table.tsx, which
already use the established ResponsiveTable wrapper. Add a MemberCard
component and wrap the table so it stacks as cards below md instead of
forcing a cramped in-card horizontal scroll on a 375px viewport.
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
* [b25fca69] Stream2-B: Header integration for command palette (#675)
* [b25fca69] Wire header search into Stream2-A command palette: click trigger via useUIStore.setCommandPaletteOpen, remove disabled input and Coming Soon tooltip remnants
* [b25fca69] Wire header search into Stream2-A command palette: click trigger via useUIStore.setCommandPaletteOpen, remove disabled input and Coming Soon tooltip remnants
---------
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
* [e4ce5b9a] Stream3-A: Tasks page List|Kanban tabs + kanban embed (#674)
* [e4ce5b9a] feat(tasks): add List|Kanban tabs to tasks page sharing URL filter state
Add top-level List|Kanban Tabs above the tasks page filter bar. List
tab renders the existing TaskFilters+TaskTable unchanged; Kanban tab
embeds the existing DevKanban/QaKanban/PrReviewKanban/PmKanban views
via nested sub-tabs (dev/qa/pr-review/pm), mirroring the standalone
/kanban page's own tab styling (tooltip-wrapped triggers, pickTab
helper). Both tabs read/write `tab`/`view` query params through the
page's existing updateParams pattern, so all filters persist across
tab switches. The four kanban view wrappers gain an optional
controlled team/onTeamChange pair so the team filter is shared
bidirectionally with the List tab's team filter, while staying
backward compatible (uncontrolled, initialTeam-only) for the
standalone /kanban route. KanbanBoard's dnd-kit drag-and-drop and
mobile single-column navigation are untouched.
* [e4ce5b9a] docs(tasks): add tasks-page-tabs.md documenting List|Kanban tab structure and shared filter state
---------
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
* [18c52802] feat(panel): redirect /kanban to Tasks kanban tab, remove sidebar entry, swap bottom tab bar to Agents (#679)
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
* Stream4-B: Responsiveness fixes — remaining dashboard pages (#676)
* [fee25542] fix(a11y): bump sub-36px icon-sm touch targets to 36px on remaining pages
Both kb-search-bar.tsx (Knowledge Base search clear button) and
self-hosted-section.tsx (Settings token show/hide button) used
Button size="icon-sm" (32px) for an absolutely-positioned input
adornment, below the 36px minimum touch-target size. Bumped both
to size="icon" (36px, matching the 36px input height) and adjusted
the absolute-position offset so the button still sits fully inside
each input's existing right padding reservation.
Audited every remaining dashboard page (everything Stream4-A's
wide-content/table fixes didn't already cover): no un-wrapped wide
tables remain (every <Table> already rides ResponsiveTable), and
every DialogContent across the repo already inherits or supplies
max-h-[*vh] + overflow-y-auto, so dialogs stay usable at small
viewport heights.
* [fee25542] fix(a11y): re-land sub-36px touch target and overflow fixes after sync_branch reset them again
Re-applies the fda2ac0c fix content a third time -- sync_branch's
rebase+force-push reset the branch and working tree back to the stale
f9f45d9f ref (the round-1-only state) instead of preserving the local
commits ahead of it, discarding the round-2 fix yet again.
- quick-actions-card.tsx (Overview dashboard customize dialog): reorder
arrows drop the h-6 w-6 override, falling back to Button's 36px icon
default
- agent-card.tsx (Agents page grid): DM / dedicated-chat / actions-menu
icon buttons drop their h-6 w-6 override, now 36px
- product-card-grid.tsx / project-card-grid.tsx (Products/Projects card
view): edit/external-link icon buttons drop h-6 w-6, now 36px
- environment-ladder-editor.tsx (Edit Project dialog): move-up/move-down/
remove-rung icon buttons drop their h-6/h-8 overrides, now 36px; the
per-rung row now scrolls horizontally within its own bordered box
(overflow-x-auto + min-w-max) instead of overflowing at 375px now
that the icon buttons are back to full width
- acceptance-criteria-editor.tsx / dependency-selector.tsx (task create/
edit dialogs): remove-row icon buttons drop their h-6/h-8 overrides,
now 36px
- tab-commits.tsx: "Linked Commits" header's fixed 3-column grid now
stacks to one column below sm, and the branch/PR badge row scrolls
horizontally in its own container instead of the page
panel lint + tsc --noEmit are both clean.
* [fee25542] fix(a11y): bump tab-commits.tsx delete-commit button to 36px touch target
The per-commit unlink button used className="h-7 w-7" (28px), the one
sub-36px target the prior re-land commits (fda2ac0c/8e00303f/d8408c77/
34ea8fe1/e6bee7c8) didn't cover -- their content only fixed the header
grid-stack overflow in this file, not this button. Bumped to h-9 w-9
(36px) matching the Button component's own size="icon" default used
everywhere else in this fix series, and bumped the icon from h-3 w-3
to h-3.5 w-3.5 to stay visually proportional at the larger target.
panel-gate (lint + tsc --noEmit + vitest) is green.
* [fee25542] fix(a11y): remove trailing narrative JSX comments in self-hosted-section.tsx
Removes the 9 {/* ... */} comments flagged by the conventions validator's
no_inline_comments rule (F-8db499ac) — Header, Base URL input, Auth token
input, Test Connection button, inline result badge, and the three empty-
state section markers. Each block is already self-evident from its JSX
composition (distinct Input/Button/Badge groupings and conditional guards
showNoUrlState/showErrorState/showNoModelsState/showModelList), so no
docstring/JSDoc replacement is needed. Pure deletion, no behavior change.
The two remaining open findings (F-8a39564c, F-4308219d) allege the
touch-target/overflow fixes across 8 files are missing from this branch --
re-verified via roboco_git_log(branch=<task branch>), which reads the real
ref directly, that the branch tip is c629c3e1 and already contains those
fixes (h-9 w-9 delete button + overflow-x-auto header row in tab-commits.tsx,
overflow-x-auto rung rows in environment-ladder-editor.tsx, no shrunk
icon-sm/h-6/h-7 overrides left in the other 6 files), confirmed by reading
every file on disk in this worktree. No code change needed for those two;
resolved via verification evidence instead of a 9th re-land.
pnpm lint + pnpm typecheck both clean.
---------
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
* [1ff154da] restore(panel): re-add AutoRefreshDriver and ScrollJumpButtons to dashboard layout (#691)
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
* [6ee71578] Add sequence field to task dialogs; fix pr_gate forms-audit.md findings (#693)
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
---------
Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
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>
* [73275ff0] fix(panel): review follow-ups — budget help text, touch-visible unlink, docs consolidation
- The task budget help text and validation toast said empty falls back
to a task-type default; that default table was removed (null = no
cap) — both strings now say so, and the forms audit row documents
the correction instead of claiming ok over a stale label.
- The per-commit unlink button was hover-revealed only, invisible on
touch devices and to keyboard focus; it now also reveals on
focus-visible and coarse pointers.
- The forms audit moves from the docs root into the governed
docs/frontend/forms/ tree, cross-linked both ways with the project
fields reference it overlapped, and both are registered in the
frontend docs index; the tasks-page-tabs doc's standalone-reuse
rationale now states the /kanban redirect reality.
- The two 20px tree-expand chevrons in the tasks table are left as-is
deliberately: explicit dense-row overrides, where a 36px target
would break table density.
---------
Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
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: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: Renzo F <45401804+rennf93@users.noreply.github.com>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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>
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>
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>
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>
ROBOCO_AGENT_TOOL_CALL_HALT/_WARN were read by the in-container SDK
server and defined in config, but reached no compose environment stanza
and no .env.example — the third dead-on-arrival env var of this class.
Live consequence (2026-07-23): the 300-call default halted the
responsiveness-audit dev twice mid-task ("Agent budget exceeded;
terminating container"), releasing and respawning it in 300-call slices.
Defaults raised to halt=600/warn=200 in the build compose (registry
compose passes them through unset), matching the already-patched NAS.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
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>
* 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>
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>
The 0.27.0 release proposal executes its STORED drafted changelog, and
the current proposal predates #663 — approving it as-is would ship a
release whose commit range includes both #663 fixes with no changelog
line, which the next readiness sweep would then flag as curation gaps.
Documenting them in [Unreleased] so a re-originated proposal drafts
complete; verified against the gap-check matching rules (every required
commit in v0.26.0..HEAD matches by #PR or exact summary).
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
* 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>
The chatgpt.com/codex/install.sh installer returns 'resource is denied'
to non-browser clients in CI, breaking the agent-codex image build.
Switch to npm install -g @openai/codex@<pinned>: the package has no
postinstall network fetch (verified) — its native binary rides an
optionalDependency served from the public npm registry, and node is
already in the base image. Verified locally: install succeeds, codex
--version runs.
* 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>
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>
* 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>
* 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>
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>
scripts/bootstrap.sh: idempotent bring-up for the pull-and-run deploy.
Fresh .env: copies .env.example and injects the three required secrets
using the documented one-liners (the panel token via the exact HMAC
formula issue_panel_token uses), with a standing-credential warning —
and a louder one when cloud auth is detected — since compose's :? guard
refuses an empty token unconditionally (verified live). Reused .env:
never touched, but the three required vars are pre-validated with
pointed remedies instead of compose's opaque interpolation error. Then
pull + up -d + a doctor-style readiness sweep grounded in the real
surfaces (root /health, /api/auth/status through nginx, the verbatim
'Alembic upgrade finished' log line, ollama list), each stage failing
loud with the command to run next. Exposed as make quickstart; README
leads with it and keeps the manual steps as 'what quickstart does'.
Also found and fixed along the way: the documented registry quickstart
was already broken — nginx's :?-required ROBOCO_PANEL_AGENT_TOKEN ships
empty in .env.example, so the 4-step path failed at compose config.
release.yml gains a pull-smoke job (fresh runner, own GHCR login,
needs publish-images): literally pulls the registry compose against the
just-published tag, guarding the missing-image regression class that
already happened once.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
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>
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>
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>
* 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>
Root cause first: no viewport export existed anywhere, so viewport-fit
was never 'cover' and every env(safe-area-inset-*) resolved to 0 on
notched iPhones — content under the status bar, dock without real
home-indicator clearance. The export lives on the (tg) group layout
(server component), NOT app-wide: the dashboard shell has no safe-area
padding and must not inherit cover.
Also: min-w-0 on four truncating flex children that overflowed their
justify-between rows (chat names/previews, fleet task titles);
object-contain on the approvals video (letterbox instead of distort on
short phones); break-words on the changelog pre / task description /
quoted mention; touch targets bumped to >=36px (sheet close, segmented
controls, ack button, chips, back button, bell, jump-to-latest,
cut-toggle); overflow-x-hidden backstop on the (tg) main scroller; fleet
avatar strip sliced to 3 with a +N badge instead of silent clipping.
Verified: pnpm typecheck clean, lint 0 errors, panel suite 870/870.
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
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>
The pg_dump sidecar wrote its dumps to the same disk it protects — one
disk failure lost both. Setting ROBOCO_BACKUP_MIRROR_DIR in .env to a
path on a different disk (external/remote mount) arms a mirror step after
every successful dump: tmp+rename copy, mirror pruned to the same
BACKUP_KEEP, unwritable mirror logs-and-skips without blocking the
primary. Unset, the script never attempts a copy — no fake off-disk
copies on the same disk. Docs gain the mirror setup and a quarterly
restore drill (throwaway pgvector container, pg_restore, row-count
sanity check).
Co-authored-by: Renn F <rennf93@users.noreply.github.com>