config-reference.md (indexed into the KB agents trust at runtime)
documented a non-existent ROBOCO_SECRET_KEY/JWT and token-expiry setting
and omitted the real one. Replace the Security table with the actual env
vars — ROBOCO_ENCRYPTION_KEY (Fernet), ROBOCO_AGENT_AUTH_SECRET,
ROBOCO_AGENT_AUTH_REQUIRED, ROBOCO_PANEL_AGENT_TOKEN — and drop the
phantom Logging/Sessions sections (no backing config fields).
regenerate_verb_tables.py imported roboco.api.schemas.v2, which no longer
exists (schemas moved to v1), so it raised on import and the generated
verb/tool tables could never be refreshed — leaving _generated/verbs.md
and the per-role prompts stale (e.g. listing submit_for_qa, omitting the
notify_*/channels/progress/pr_update content tools). Repoint the imports
to v1, fix the renamed schema (OpenPrRequest), and regenerate.
A documentation audit against the code surfaced several stale claims:
- Agent count: the roster is 19 AI agents (the UX/UI cell has two devs,
ux-dev-1 + ux-dev-2), not 18 / a single UX dev. Fixed in README,
CLAUDE.md, base.md, and docs/ux_ui.
- API: domain routes are mounted under /api, not /api/v1 (the /api/v1
prefix is the agent gateway only); dropped the non-existent /api/v1/test
group; fixed the orchestrator-status path in deployment.md.
- Quick Start uvicorn target is roboco.api.app:app (the api package
deliberately does not export app).
- Verb table: the developer PR verb is open_pr (renamed from
submit_for_qa); the lifecycle's canonical module is
foundation/policy/lifecycle.py (enforcement/task_lifecycle.py is a shim).
- Backend team stack: vector store is PostgreSQL + pgvector (via piragi),
not Qdrant; mypy targets roboco/, not src/.
- .env.example: replaced the phantom Qdrant/OpenAI blocks with the real
Ollama/RAG settings.
A step-by-step guide to the workflow — give the Board a task, approve,
the cells build/review/document, the Main PM opens the final PR — each
step illustrated with a panel screenshot. The screenshots live under the
tracked docs/images/ (the source logos/ tree is gitignored, so embedding
from there would render broken on the public repo).
docs/internal/ held internal scratch and business-strategy material
(architecture dumps under old/, plus drafts) that should not live on the
public repo. Remove the whole tree from tracking — local copies are kept
— and gitignore docs/internal/ so it can't be re-added by accident. Also
gitignore the internal smoke-findings tracker.
(History scrub of the already-pushed copies is a separate follow-up.)
* fix(gateway): push the branch before QA handoff so reviewers see the latest commits
The commit content tool commits locally without pushing; only open_pr pushed
the branch. On the first submission that was fine, but a fix committed while
addressing needs_revision never reached origin (open_pr is skipped once the PR
exists), so QA — which reviews the remote PR branch — re-reviewed the stale
remote and re-failed the task on every cycle, a loop that never converged.
i_am_done now pushes the task branch (idempotent; a no-op when nothing is
unpushed) as part of the shared submit gate, covering both the normal and
resume-from-verifying paths. A push failure blocks the handoff with a clear
remediation rather than parking the task in awaiting_qa with commits that exist
only in the developer's local workspace.
* fix(orchestrator): don't reap a stale claim while the agent's container is alive
The stale-claim reaper released any claimed/in_progress task whose
last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain
gateway calls, so a developer deep in a long edit/test cycle outran the TTL and
had its claim reaped mid-work — churning the task and risking a double spawn
against the still-running container.
The reaper now skips a task whose assignee still holds a live (ACTIVE) agent
instance, trusting container liveness — the ground truth — over the heartbeat
proxy. The check is defensive on missing fields so a heartbeat-only caller (and
the reaper's existing unit tests) behave exactly as before.
* fix(gateway): refuse to unblock a task while a dependency is unfinished
A PM unblock on a dependency-gated task moved it straight to in_progress,
overriding the dependency — letting a dependent proceed without its upstream's
work (e.g. a frontend task built before its UX design lands). A dependency
block is meant to clear on its own via _unblock_dependents the moment the
upstream reaches a terminal state.
unblock now refuses while any dependency is still non-terminal, returning a
clear remediation that the block resolves automatically. Manual unblock remains
available for genuine, non-dependency blockers.
* fix(gateway): release a dependency-blocked claim to pending instead of looping
A task that reached claimed/in_progress with an unfinished dependency was left
in that state when the claim guard rejected, so the orchestrator's respawn loop
kept reviving its assignee — which could make no progress — burning work for
nothing.
The claim guard now releases such a task back to pending. claimed -> blocked is
not a legal transition, so pending — held by the dispatch dependency filter — is
the lifecycle-correct resting state: the respawn loop ignores pending tasks, and
_unblock_dependents re-dispatches it once the upstream reaches a terminal state.
release_dependency_blocked_claim shares a _force_unclaim_to_pending core with
unclaim_for_reaper so both record a truthful work-session abandon reason.
* feat(security): warn at startup in header-trust mode + document the auth posture
When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id /
X-Agent-Role headers without a signed token, so any client that can reach it may
act as any role (including 'ceo'). The API now logs a clear warning at startup
in this mode, and the README gains a Security section documenting the auth
posture and how to harden it. Acceptable only on a trusted private network — do
not expose the API to untrusted networks.
* fix(workspace): scope the refresh fetch to current + default branch
ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to
keep every origin/<branch> ref current. On a monorepo with many accumulated
feature/* branches that exceeds the refresh timeout, the fetch silently fails,
and the workspace keeps a stale base — so an agent builds on an out-of-date
branch.
The refresh now fetches only the workspace's current branch and the repo's
default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers
near-nothing and can't time out. Readers need their own branch and the default;
the integration branch is refreshed at branch-creation time.
* fix(git): refresh a dependency-blocked task's branch off the current integration tip
A cross-cell dependent (e.g. a frontend task waiting on the UX design) was
branched off a base captured before its upstream merged into the integration
branch, and the branch was never re-synced — so the agent built on a stale
snapshot with none of the upstream's work.
Two changes close the gap:
- release_dependency_blocked_claim now clears branch_name, so the re-claim
(after the dependency clears) re-runs branch creation.
- create_branch, when the branch is already on disk with no commits of its own,
resets it onto the freshly-pulled base — the dependent now builds on the
current integration tip. A branch carrying real commits is left untouched, so
no work is discarded; the cell->leaf cascade carries the upstream down to the
dev branch automatically.
* refactor(gateway): drop the sibling-sequence claim guard
Sibling sequence no longer gates a claim. Cross-cell ordering is
enforced by task dependencies — a cell task that depends on another is
held until its upstream reaches a terminal state, a stronger,
status-aware gate than the sequence-number check. That check was
dormant in practice anyway: every fan-out child carries sequence 0, on
which the guard short-circuited. `sequence` stays a sibling-ordering /
dispatch-priority field (list_pending ordering and the panel).
Removes sibling_sequence_guard and its _earlier_blocking_sibling
helper, the now-unused skip_sequence parameter threaded through the
claim verbs, and the sibling fetch that fed it.
* feat(gateway): sort a cross-cell dependent after its upstream
When the frontend cell task is wired to depend on its UX/UI sibling, set
its sequence to the upstream's sequence + 1 so it sorts after the design
it waits on — list_pending ordering and the panel now show UX ahead of
the implementation it gates, in either delegation order.
Adds TaskService.set_sequence (the sibling-ordering field is a service
write; it carries no claim-gating semantics — dependencies gate claims).
* feat(gateway): make the backend cell depend on UX too
UX/UI design defines the screens and API contracts both implementation
cells build against, so the backend cell — not just the frontend — waits
on the UX/UI cell task in a product fan-out and sorts after it. Wires in
either delegation order: a backend task delegated after UX gets the
dependency directly; a UX task delegated after a still-pending backend
sibling retro-wires it.
Mirrors the existing frontend wiring (_depend_backend_on_ux and
_depend_pending_backends_on_ux). Backend is held by the same dependency
gate, so it costs no extra dispatch churn.
* fix(websocket): forward notification acks instead of logging them incomplete
The bridge handler serves both notification.sent and notification.acked,
but acked events carry `agent_id` (the acking agent) rather than
`recipient_id`, so every acknowledgement tripped the missing-field guard
and logged "Incomplete notification event" instead of reaching the panel.
Accept either field as the recipient.
* feat(api): hint the full UUID when a truncated task id fails validation
Agents copy the 8-character task prefix the system shows them (the commit
prefix, task summaries) and send it as task_id, which fails UUID
validation with an opaque "invalid length" 422 and wastes a call. The
request-validation handler now detects a task_id UUID error and attaches
a `remediate` hint telling the agent to retry with the full 36-character
UUID from its task envelope.
* fix(audit): record the blocked transition when a task is escalated
Escalation sets a task to blocked by writing task.status directly, which
bypassed the validated transition helper and so never emitted a
task.blocked audit row — the lifecycle moved but the Auditor saw nothing.
Extract the audit emit from the central transition helper into
_emit_status_transition_audit and call it from the escalate path,
capturing the prior status and outgoing owner before reassignment so the
row is attributed correctly.
* fix(docs): stop doubling the docs path so design specs index into RAG
The documenter sometimes hands a doc path already rooted at docs/, and
joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/...,
so the file was never found and the spec never indexed — the frontend
cell could not retrieve the UX design over RAG. Normalize the path
before joining: trust an absolute path, otherwise strip a single
redundant leading docs/ segment.
* feat(security): let the control panel authenticate in secure mode
With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid
HMAC token, which locked the human control panel out — it sends role
headers but no token. nginx, the only trusted hop between the browser
and the API, now injects the CEO token on /api and /ws, so the browser
never holds the signing secret. The injected value is just the existing
per-agent token issued for the CEO identity (issue_panel_token), so the
token-verification path is unchanged. An empty value (dev/header-trust
mode) renders to no header.
`make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN
in .env before enabling secure mode. .env.example and the README
Security section document the flow.
* chore(compose): consolidate the two compose files into one
docker-compose.yml and docker-compose.yaml had diverged: .yml — the file
Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the
/app/manifests bind-mount, while .yaml had the manifests mount but not
the base URL. Merge the union into docker-compose.yml and delete the
duplicate so there is one source of truth and no "multiple config files"
warning.
This activates the manifests mount in the deployed file: without it the
orchestrator writes per-agent tool manifests to its ephemeral container
fs, they never reach the host for the daemon to bind-mount, and agents
fall back to all-verbs registration. Drop the stale .yaml reference from
the config.py docstring, the labeler, and the CI path filters.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
addNotification prepended every delivery and re-incremented unreadCount /
pendingAckCount, so a re-fetched or replayed notification stacked duplicates
and re-inflated the counts — already-acknowledged notifications re-surfaced to
the CEO and the pending badge kept climbing. Dedupe by id: update in place on
re-delivery; only add + count a genuinely new notification.
create_session and create_session_with_access_check closed the group's active
session and opened a new one on every call. A group is meant to have ONE live
session (groups.active_session_id) that all participants post into, so this
churned a single conversation across many sessions — the smoke run showed ~one
session per message, and the CEO could not hold a conversation in a channel.
Both now reuse the live session when one is active and only open a fresh one
when none is (closed via timeout / boundary / merge), matching the existing
get_or_create_active_session contract.
merge_pr_for_task merged using the client-provided project_slug, which a
coordination root (no project of its own) cannot supply. For a root, resolve
the repo from its product server-side so the CEO's approve-&-merge of the
root->master PR works without the panel knowing the repo. Non-root tasks keep
the client slug. Completes the root->master->CEO chain for the monorepo case
(multi-repo N-PR fan-out remains a follow-up).
main_pm_complete opens the root->master PR via create_pr(t.branch_name).
create_pr resolved the repo from task.project_id (null for a root) and would
raise. Route it through _project_for_task so the root's PR resolves the
product's repo. Additive — non-root tasks unchanged.
Branch->project resolution (_project_slug_for_branch, _workspace_for_branch)
read task.project_id, which is null for a coordination root — so root-level
git ops (the root->master PR, the CEO merge) could not resolve a workspace.
A new _project_for_task falls through to the product's first distinct repo
(monorepo => the single repo) when project_id is null. Purely additive: a
task with a project_id resolves exactly as before; only the previously-
unresolvable root case changes.
Every push/fetch to a private monorepo from a self-hosted runner takes
~1-2s, so a 1s threshold tagged routine ops as 'slow git op' on every
operation — pure noise. 5s only fires on genuinely slow ops.
The learning singleton was created on demand but initialize(optimal_service)
was never called, so record_learning() always raised "not initialized" and
every task completion logged "Failed to extract learnings". The lifespan now
wires it to OptimalService once RAG is up (skipped when RAG is disabled).
tracing_gap and incomplete_input rejections set missing and remediate but
left message null. The audit log records message (not remediate) and agents
keyed on message, so a rejected agent saw a null reason and retried the same
verb until it burned out instead of reading remediate and self-correcting.
Both builders (and from_decision) now derive a non-null message that folds
the missing tokens and the actionable remediate into one line, so the agent
and the audit trail always see what was missing and how to fix it.
pr_merge (the gateway path a cell PM uses to merge a leaf/cell PR up the
hierarchy) accepted any target, including a repo's default branch — the
hole that let cell completion land on master. It now refuses any target
equal to the project's default branch with a CEO_ONLY error: a root→master
PR is merged solely by the CEO via approve-&-merge (merge_pr_for_task,
already CEO-gated from awaiting_ceo_approval). Agents open the master PR
and escalate; they never merge it.
Belt-and-suspenders to the integration-branch routing: even if a target
ever resolved to master, this blocks the merge at the GitHub-API boundary.
The coordination/fan-out root carries a product (cell->repo map) but no
project of its own, and was forced branchless — so a cell's parent-branch
resolution fell back to the project default (master), and cell completion
merged each cell straight to master, bypassing the Main-PM integration
point and the CEO merge gate.
Per the locked branch model (master <- feature/main_pm/{root} <- cell <-
dev), the root is now the Main-PM integration point: on claim it cuts
feature/main_pm/{root} off master in EACH distinct repo the product spans
(monorepo => 1, multi-repo => N). Cells then branch off it via the existing
ancestor-branch resolution, so cell work never targets master.
- ProductService.distinct_project_ids: enumerate the repos a product spans
- TaskService._create_branch_in_project: project-parameterized branch
creation split out of _auto_create_branch
- TaskService._ensure_coordination_root_branches: cut the integration
branch in each repo; graceful empty when the product has no cell map yet
- _ensure_branch_for_task routes a product-backed root here, not to no-op
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Extract per-entry chown+chmod into _own_and_grant_rw and the pruned
workspace walk into _iter_ownable_entries. The single function carried a
no-op guard, an explicit-root chown, an os.walk with in-place pruning, two
path comprehensions, an inner entry loop, and a failure tally — cyclomatic
rank C. Behaviour is identical (root + every non-pruned entry chowned and
granted owner/group rw); the main function is now a guard, a sum() over the
entry iterator, and the warning, all rank A.
The lifecycle fix routes a never-claimed (no-branch) blocked task to pending on
unblock; these two tests asserted in_progress on a no-branch task. Give them a
branch so they exercise the claimed-task resume path they intend (no-branch ->
pending is covered by new unit tests).
evidence is read-only, but the cross-agent ownership gate blocked a caller from
inspecting a task its own work depends on (a frontend cell could not read the UX
task it was waiting on). Exempt reads where the target is a dependency of a task
assigned to the caller. Also dropped stale internal refs from the docstring.
A prior claim attempt can create the branch on disk before the DB records
branch_name (the claim rolls its fields back, but the on-disk branch persists).
A plain checkout -b then fails 'already exists' (exit 128), and the resulting
error-handling cascade is how branch creation spiraled into INTERNAL_ERROR.
Fall back to checkout <branch> when checkout -b returns non-zero.
A task blocked before it was ever claimed (a dependency-gated claim that got
escalated) has no branch. Legacy unblock() forced in_progress, which the
dispatcher refuses (state=in_progress but branch_name unset) -> a spawn-refused
loop. Add the blocked->pending transition and route no-branch tasks there so
they are freshly claimed (the claim gate then holds them cleanly while the
dependency is unmet). Branched tasks still resume in_progress. Artifacts
regenerated.
The map told agents to hand-construct mcp__<server>__<verb> tool names, which
do not match what their runtime exposes — agents fumbled (No such tool
available: mcp__roboco-do__evidence) and had to retry the bare verb. It also
did not reduce the opening-move fumbling it targeted; agents recover via the
gateway's own remediate hints regardless. Net-negative. Reverts 3d04943 and
its follow-up 5462fe3.
The chown failures were never a userns-remap issue (the NAS daemon has no
userns-remap configured) — they were the .git-only chown leaving the working
tree root-owned, fixed separately. userns_mode:host on the orchestrator alone
was a no-op at best and a latent footgun (orchestrator un-remapped while agents
are not) if remap were ever enabled. Keep the uv-in-runner + env=production.
The .git-only walk left the working tree root-owned, so agents (uid 1000)
could not write any file — every mkdir/open/commit failed with EACCES and the
run died. Walk the whole workspace, chowning the root + tracked files + .git,
while pruning the heavy gitignored trees (node_modules/.venv/dist/...) that
made the full walk slow. Verified on the host: uid-1000 write succeeds after.
pyjwt 2.12.1 (pulled transitively by mcp and msal) carries four disclosed CVEs fixed in 2.13.0; add a uv constraint-dependencies floor and refresh the lock. Also apply ruff formatting to the changelog scaffold f-string in the version-bump script.
The request and service models defaulted default_branch to "main" and the
response converters fell back to "main", so omitting the field on the create
route persisted "main" instead of the DB column default of master. Flip every
default_branch default and fallback to master.
A dev subtask is always pre-assigned (assigned_to=<dev>), so it never
flows through the unassigned claim pool's dependency filter
(list_pending(filter_by_dependencies=True)). Every path that acts on a
pre-assigned pending dev subtask previously ignored dependency_ids: the
orchestrator spawned the dev container, give_me_work offered the task,
and the claim verb accepted it — letting a frontend dev code ahead of an
unfinished UX/UI design.
Hold the pre-assigned dev at each path it actually arrives by, until
every dependency reaches a terminal state:
- orchestrator _validate_task_for_spawn now consults dependency_ids via
_check_dependencies_terminal and skips the spawn while any dependency
is non-terminal (fail-closed on an unreadable dependency);
- TaskService.list_pending_for_agent excludes a pre-assigned task with
unmet dependencies so give_me_work does not offer it;
- the Choreographer claim guard set rejects the claim with a clear
remediate via a new unmet_dependency_guard.
Add TaskService.unmet_dependency_ids as the single source of truth for
"which dependency IDs are not yet terminal" and route the existing
inherit_unmet_dependencies through it.
The generated session briefing told every role with i_will_work_on that
note(scope='decision') is required before claiming. That is wrong: the
i_will_work_on gate is journal:note_at_claim, satisfied by
has_note_for_task, which queries JournalEntryType.GENERAL. Only
scope='note' maps to GENERAL; scope='decision' maps to DECISION_LOG and
is the PM's i_will_plan gate. Emit scope='note' on the dev-claim branch
and keep scope='decision' on the i_will_plan branch.
The existing gate test mocks both board reviewers as already-idle, so it never
exercises the spawn -> active -> exit -> idle -> next-tick transition — the
boundary the gate actually guards. Add a real-DB integration test that seeds a
board/coordination task plus the agents the handoff resolves against, dispatches
both reviewers through the real handler (the spawn stub leaves each ACTIVE in
_instances exactly as a real container spawn does), asserts board_review_complete
stays False while either reviewer is active, then marks both idle and runs the
next tick to assert the flag flips True and the formal CEO approval notification
is persisted.
The docs and knowledge-base HTTP routes enforced authorization inline and
raised raw HTTPException(403) with no recovery hint, so agents received
remediate=null and an un-actionable error, and the optimal route held the
RBAC decision itself (a layer-separation violation).
Move the knowledge-base authorization decision into a gateway module
(services/gateway/kb_authz) that returns an Envelope.not_authorized with a
non-null remediate naming the roles allowed to perform the action. The docs
RBAC already lives in DocsService; render its UnauthorizedError through the
same gateway helper. Both route groups now return the Envelope wire-dict at
top level (HTTP 403) instead of a bare detail string, keeping the routes
thin (HTTP translation only). Permitted callers are unaffected.
A frontend cell task waits on the UX/UI design, but a dev/code subtask
delegated under it did not inherit the unresolved dependency, so the
developer became dispatchable and coded ahead of the design. Propagate
the parent cell task's still-unresolved dependencies onto the new subtask
on delegate, reusing the existing dependency model: the subtask is held by
list_pending(filter_by_dependencies=True) until the UX task is terminal.