mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
a7b970a3b2cb55a4847681876c8eee89a78ddae0
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b520e69dea |
fix(gateway): brand_voice reaches HoM & PO exploration briefings
board_triage's idle branch built its briefing without full=True, so company_goals (brand_voice/north_star, migration 061) never reached the Product Owner's roadmap-exploration spawn or the Head of Marketing's feature-spotlight spawn — both always hit the idle branch, yet both spawn prompts claim the charter is 'already in your briefing'. Added a scoped include_company_goals flag to _briefing_for (via a _resolve_company_goals helper; xenon B preserved) that fetches only the cheap charter singleton without full's other heavy sections; board_triage's idle branch opts in. Strategic branch + auditor untouched. 6 new tests. |
||
|
|
df87fcf059 |
Chore/logical gaps element sweep fixes (#287)
* [sweep] lifecycle: 6 confirmed gaps fixed (cancel-ceo-gate, claim_pr_review gate, needs_team_match, valid_next_verbs narrowing, pr_reviewer unclaim, complete side_effect ordering)
* [chore] logical-gaps: route-layer force gate + privileged-field gate + pre-task audit attribution
tasks.py (5 gaps):
- _HATCH_OVERRIDE_STATES expanded to 7: a privileged PATCH INTO a gate
state (completed/cancelled/awaiting_{qa,documentation,pr_review,
pm_review,ceo_approval}) now requires explicit force — the panel hatch
is no longer a quiet click that drops a task into/out of a human gate.
- _RESURRECT_SOURCE_STATES: a privileged PATCH OUT of a terminal status
(completed/cancelled) resurrects finished work and likewise requires
force, audited as an override.
- _PRIVILEGED_UPDATE_FIELDS gate: a bare task owner (UPDATE_OWN, no
ASSIGN) cannot self-reassign / re-team / re-parent / re-depend /
re-block / rewrite-plan / re-project its task — those structural fields
are PM-gated; the REST surface must not bypass the verb-layer's
reassign/delegate/triage gate. A 403 names the touched fields + the
verb to use instead.
- pre-task create denial: a role that cannot create tasks is now logged
via log_task_creation_denial (distinct task_creation target_type +
attempted payload) instead of a 'N/A' task_id that coerced to NULL and
left the role-escalation attempt unattributable.
audit.py:
- split log_task_action_denial (5-param, under PLR0913) from
log_task_creation_denial (4-param) — the create path has no task_id;
the non-UUID sentinel (N/A) is preserved in details[target_id_raw]
rather than dropped to a NULL target_id indistinguishable from any
other NULL-target denial.
tests:
- test_tasks_routes.py: parametrized admin-override gate (force
required for gate + terminal states, force succeeds).
- test_tasks_route_privileged_fields.py: dev owner 403 on
assigned_to/team/parent_task_id, 200 on dev-facing description.
- test_audit.py: pre-task attribution via log_task_creation_denial +
non-UUID sentinel preservation.
* [chore] logical-gaps: kanban board column coverage + status-class fixes (6 gaps)
models/kanban.py:
- DEV_COLUMNS: cover all 15 lifecycle statuses (was 7; dropped BACKLOG,
PAUSED, VERIFYING, NEEDS_REVISION, AWAITING_PR_REVIEW, AWAITING_PM_REVIEW,
AWAITING_CEO_APPROVAL, CANCELLED). A dev whose task bounced to
needs_revision or sits in a gate used to see their own task vanish.
- PM_COLUMNS: add the gate/revision/paused/cancelled/backlog columns so the
cell PM sees the QA->docs->PR-review->PM-review->CEO chain on its board.
- QA_COLUMNS: drop the 'In Review'->VERIFYING mapping. VERIFYING is the dev's
self-verification (task still with the dev, not with QA); it misrepresented
dev mid-verification as active QA work.
services/kanban.py:
- _build_flat_board: add an 'Other' fallback column for any task whose status
matches no configured column, so total_cards == sum(card_count) and no card
is built-then-silently-dropped (the vanished-card leak).
- get_qa_board: drop VERIFYING from qa_statuses (consistent with the column
change).
- get_documenter_board: scope to task_type=documentation so a dev IN_PROGRESS
code task sharing the cell team no longer appears under 'Gathering'.
- get_main_pm_board_flat: widen the status filter to include PENDING/CLAIMED/
COMPLETED and route those to the incoming/distributed/done columns, which
were structurally always empty under the in-flight-only filter.
tests/integration/test_kanban_service.py: parametrized coverage of every
dropped dev status, PM gate/revision states, QA excludes VERIFYING,
documenter excludes dev code tasks, flat Main PM incoming/distributed/done
populated, and the 'Other' fallback invariant.
* [chore] logical-gaps: lifecycle-enforcement validators + status-class fixes (5 gaps)
enforcement/task_lifecycle.py:
- drop the spurious VERIFYING->awaiting_documentation legacy edge. The
canonical exit is submit_qa -> awaiting_qa -> (qa_pass) ->
awaiting_documentation; the direct edge bypassed the entire QA review hop
(ungated — no role gate existed for it).
- is_waiting_state: add awaiting_pr_review. The PR-review gate parks the PM on
the reviewer; it is a waiting state. The hard-coded set was never updated
when AWAITING_PR_REVIEW was added to the enum, so the gate status was
miscategorized as active.
foundation/_validate_lifecycle.py:
- _check_status_enum_coverage: replace the tautology (STATUS_GRAPH keys every
Status by construction) with a real bidirectional check — every non-terminal
Status is the source of a transition (catches orphan states), and every
source/target referenced is a real Status member (catches stray-string
targets).
- _check_terminal_exits: split the {COMPLETED, CANCELLED} reachability into a
COMPLETED-path requirement + a cancel-exit requirement. The cancel fan-out
made the old check structurally trivial — a status whose sole exit was cancel
passed with no real forward completion path.
- _check_status_enum_parity (new, registered): cross-check spec.Status against
models.base.TaskStatus at import so the ORM column type and the lifecycle
map cannot drift (TaskType had this guard; Status did not).
tests: verifying->awaiting_documentation rejected, self-fail preserved,
awaiting_pr_review is waiting, mutually-disjoint classification invariant,
status enum parity, stray-string-target / orphan-source / cancel-only-exit
validator rejections.
* [chore] logical-gaps: stream-bus poison-pill ACK + dead-letter, periodic reclaim, cancelled-handler marker cleanup (3 gaps)
stream_bus.py:
- _handle_message isolates Event.from_json in its own try/except; an
undecodable payload (unknown EventType, bad UUID/timestamp, malformed
JSON) is dead-lettered then ACKed instead of falling through to the
broad except that only logged — a poison pill stayed pending forever
and re-failed on every reclaim. (gap: stream-bus-malformed-event-poison-pill)
- _reclaim_loop spawned alongside _listen_loop in start_listening (cancelled
in disconnect). XREADGROUP '>' delivers only NEW messages, so a runtime
handler failure left its message pending and unretried until a restart;
the loop re-runs recover_pending every 60s so the idempotency-guarded
replay actually fires. (gap: stream-bus-no-runtime-reclaim-loop)
- _run_handler_guarded marker cleanup catches BaseException so a handler
cancelled mid-flight (asyncio.CancelledError is BaseException-derived
since 3.8) clears its SET-NX marker; otherwise the guard suppressed the
very redelivery that would complete the work. (gap: stream-bus-cancelled-
handler-keeps-idempotency-marker)
TDD: 4 red->green tests in tests/unit/events/test_bus.py.
* [chore] logical-gaps: verb_runner trailing-None side-effect guard + actor_agent_id threading (3 gaps)
_verb_runner.py:
- run_intent skips the side_effects loop when a TRAILING composed action
returned None (its source-status check failed under a concurrent
transition). Previously the loop ran unconditionally on the None task
and _do_push_branch(None)/_do_pr_merge(None) crashed with a
NoneType AttributeError, turning the clean INVALID_STATE the
entry/intermediate guards give into a 500/respawn loop. The trailing
None now flows to the caller's `if task is None` handler. Latent today
(no shipped intent has both a None-capable compose and trailing
side_effects) but the runner is generic. (gap: runner-side-effects-fire-
on-trailing-none-task)
- _do_push_branch / _do_create_pr / _do_create_root_pr forward
actor_agent_id=agent.id into git_service (push_branch / create_pr),
matching _do_pr_merge. Without it, a verb on a task whose assigned_to
was cleared before the side effect falls through to created_by and
pushes from / opens a PR against the wrong workspace.
(gap: side-effect-handlers-drop-actor-agent-id)
- _do_escalate_to_ceo forwards actor_agent_id=agent.id so the
awaiting_ceo_approval audit row attributes to the specific PM/Board
agent. (gap: do-escalate-to-ceo-drops-actor-agent-id)
task.py: escalate_to_ceo gains actor_agent_id param, passed as
audit_agent_id to _validate_and_set_status and recorded as
escalated_by_agent_id in the event payload + log. escalate_to_ceo_for_agent
forwards agent.agent_id.
_impl.py: the main_pm complete->escalate path forwards
actor_agent_id=main_pm_agent_id.
TDD: 5 red->green tests (synthetic trailing-None intent, actor forwarding
for push_branch/create_pr/create_root_pr/escalate_to_ceo) + real-DB audit
test asserting the awaiting_ceo_approval row carries the actor UUID.
Updated 3 board escalate_to_ceo tests to assert the forwarded actor.
* [B-REL] release executor: idempotent half-landed retry + commit-scoped CI + decoupled workflow
Three confirmed gaps in the release fail-closed pipeline (#87/#318/#402):
#87 publish_failed retry duplicates changelog: execute() only short-circuits
on an existing tag. A publish_failed outcome (commit pushed + CI green, no
tag) left no tag, so a retry re-ran apply_version_bumps + write_changelog_entry
(re-inserting the entry above the already-present heading -> duplicate) and
commit_and_push (a second chore(release) commit). Add ReleaseOps
.release_commit_sha(version) detecting a prior release commit on the branch
(clone already at the target version); when present, skip the bump/changelog/
gate/commit pipeline and rejoin the shared CI -> publish tail on the existing
commit. No second commit, no duplicate entry.
#318 wait_for_ci polls branch-latest, not the release commit: a later push to
master during the ~40min wait made the latest run's head_sha != the release
sha forever, exhausting _CI_MAX_POLLS -> false ci_failed on a release whose
own CI was green. Thread head_sha through get_latest_ci_conclusion /
_fetch_latest_ci_run (GitHub actions/runs?head_sha=) so the gate polls the
release commit's own run; a concurrent push can no longer mask it.
#402 release CI gate reuses self_heal_ci_workflow: that setting documents an
empty-string mode for single-workflow repos which, inherited here, degraded
the fail-closed gate to the all-workflows mode git.py itself flags as
unreliable. Add release_ci_workflow (default ci.yml) and _resolve_release_
ci_workflow(); the release gate always resolves a NAMED workflow, never None.
Refactor: bundle the CI-fetch per-project inputs into a _CiRunQuery dataclass
so _fetch_latest_ci_run stays under the arg-count gate; unify the half-landed
path into execute's shared tail (drops a separate _publish_existing, one
return path). TDD red->green; ruff/mypy clean.
* [chore] logical-gaps: a2a service hierarchy gate (typed, unconditional) + persist skill on message row (3 gaps)
create_a2a_notification gated A2A hierarchy only when both ends resolved
(`if from_agent and target_agent:`), so an unattributed (from_agent falsy)
or unresolvable-target request slipped past the hierarchy matrix and
dispatched with from_agent='unknown' / to_agent='' — and a denial came back
as a bare ValueError indistinguishable from the missing-task_id ValueError.
Require both ends present, then validate via the shared typed
validate_a2a_access path (A2AAccessDeniedError + route_hint) so the legacy
notification surface enforces the same who-may-talk-to-whom invariant as the
conversation path.
send() accepts skill= and the gateway callers (qa/doc/pr_gate) pass it
expecting the receiver to learn which capability the message is about, but
send_chat_message never read it from options — silently dropped. Persist a
nullable skill column (migration 054) on a2a_messages, wire it through
send_chat_message + _msg_to_model + the A2AChatMessage model, and fix the
send() docstring (it claimed 'recorded in message metadata').
TDD: 4 red→green (skill recorded on message + surfaces in inbox; permission
denied raises typed A2AAccessDeniedError with route_hint; self-A2A raises
typed; missing from_agent raises instead of silent dispatch). 103 a2a
integration tests green; ruff/mypy clean; migration 054 verified
upgrade/downgrade on throwaway PG.
* [chore] logical-gaps: release-proposal already_published closes proposal + heartbeat-lock-loss cancels execute (2 gaps)
approve() closed the proposal only on status=='published'. A retry that finds
the tag already shipped returns 'already_published' (is_already_published),
so if a prior publish's route commit failed / HTTP 504'd, the proposal stayed
non-terminal forever — every retry returned already_published and never
closed it; only a manual cancel unstuck it. Close on both published and
already_published: the release shipped either way.
_heartbeat_loop returned silently when the lock was no longer owned (a >TTL
Redis outage let the mutex expire mid-execute), leaving executor.execute
running UNGUARDED — a concurrent approve (once Redis returns) could then
acquire the lock and _prepare_release_clone rm -rf the in-flight shared
release clone while the first execute was still mid-run_gate, re-opening the
very rm -rf-clone race the mutex+heartbeat exist to prevent. Run execute as a
task; on lock-loss the heartbeat sets a flag and cancels it, and approve()
turns the CancelledError into a structured 'lock_lost' result (an external
cancellation of approve itself still propagates — distinguished by the flag).
TDD: 2 red→green (already_published → COMPLETED not wedged; heartbeat lock-loss
→ lock_lost + execute cancelled, proposal not completed). 8 concurrency tests
green; ruff/mypy clean.
* [chore] logical-gaps: release approve async dispatch (202) — kill the 40min synchronous HTTP 504
The approve route ran the whole fail-closed execute inline: clone(600s) +
gate(1800s) + CI poll(2400s) + publish(300s) ≈ up to 85min worst case. nginx
(the single :3000 entry point, ~60s read timeout) 504'd long before it
finished, so the CEO's approve always appeared to fail even when the release
succeeded server-side — the structured ReleaseResult was unreachable over the
wire. dispatch_approve spawns the execute in a background task with a fresh
session (built from the request session's engine) and the route returns 202
'accepted' immediately; _INFLIGHT_APPROVES tracks the dispatched task for
observability (self-cleans via done-callback; the Redis mutex still refuses a
double-execute on a second click). The panel already polls GET /proposal every
30s, so it observes the final status (COMPLETED on published/already_published,
else the proposal stays open for retry); the card's approve toast now treats
'accepted' as an info 'dispatched, running in the background' instead of the
old 'Release halted' warning.
TDD: 2 route tests red→green (approve returns 202 'accepted' + the proposal
transitions to COMPLETED / stays PENDING once the background faked execute
completes; the dispatched task is awaited while the executor patch is live).
83 release tests green; ruff/mypy clean; panel typecheck+lint+format+test
green.
* [chore] mcp-servers: normalize exception bodies to Envelope + lift task_id/correlation_id on circuit_open (#232 #359 #57)
flow_server/do_server: the non-404 JSON path returned exception-handler bodies
raw (dict `error` from roboco/generic/http exception handlers, or a 422
`detail` list) — neither is the Envelope wire format the agent is prompted to
trust (string error kind + message + remediate + missing), so on any
service/validation failure the agent got no remediate and flailed until the
breaker tripped. _normalize_exception_envelope lifts the body into a real
Envelope (code -> counted string kind via _classify_dict_error_code, NOT_FOUND
-> not_found, message lifted, remediate synthesized, missing=[]; 422 -> incomplete_input with the validation detail preserved). The synthesized
envelope still flows through the breaker so a 500/422 storm trips it.
_record_and_check_circuit: the circuit_open substitution dropped task_id /
correlation_id from the top level (the SDK's envelope omits them); lift them
from the original rejection so the agent's envelope contract and ops audit-join
of the trip event still work, not just nested in inner.
intake_server._post_event: capture the relay response body under `detail` on
non-success so the grok intake agent gets the real reason (e.g. 'session not in
MegaTask scope' on a 422) instead of an opaque http_422 token with no
remediation.
TDD red->green; ruff + mypy clean; 157 mcp/SDK-breaker tests pass.
* [chore] a2a-routes: authenticate send_message responder + gate cancel task (PM-only) (#116 #423)
send_message took the responder identity from a client-supplied
metadata.from_agent, so any caller could spoof anyone (e.g.
from_agent='ceo') in the task's notes and in the spawn/notification
routed back to the original requester. Stamp the authenticated caller's
slug as the responder instead (CurrentAgentContext).
cancel_task was ungated: no auth dependency and no role check, so any
agent (or any caller) could cancel a task the lifecycle rule reserves to
PM roles (Any -> cancelled: PM roles only) — and the cascade-cancel of
all non-terminal descendants ran with a hardcoded cell_pm role and no
recorded actor. Add require_any_authenticated_agent + a PM-or-above gate,
and thread the authenticated role (into the cascade role gate) and slug
(into the cancellation note) into A2AService.cancel_task.
Tests: send_message ignores a spoofed from_agent and records the
authenticated slug; cancel rejects a developer (403) and a missing auth
header; a PM cancel threads role + slug into the service; the pre-existing
cancel success/already-terminal/not-found tests now run under a PM context
(the success test's body was missing the A2A 'name' field and false-passed
on a 422 — now genuine).
* [chore] work-session-routes: ownership check on mutating routes + stamp merge_pr merged_by from auth (#158 #271)
Every mutating work-session route keyed off session_id alone after the
role gate, so any developer could commit into / abandon / complete a
peer's active session (breaking the single-active-WorkSession invariant
and stranding that task) and any PM could merge any cell's PR — the REST
surface bypassed the verb layer's active-claimant gate entirely. Add a
shared _assert_ownership guard: dev ops require session.agent_id to be
the caller; PM merge_pr requires a cell PM to own the session's task cell
(main PM / CEO / board coordinate every cell), 404 for a missing session.
merge_pr took merged_by from the request body, so any PM could record a
PR merge under another agent's id, corrupting the merge audit trail the
completion/CEO-approval chain and metrics rely on. Drop the body param
and stamp the authenticated caller's agent_id as merged_by (the
MergePRRequest schema is gone with it).
Tests: a second dev's token hitting a peer's /commits and /abandon -> 403
(session left active); a foreign-cell PM -> 403, same-cell PM -> 200; a
spoofed body merged_by is ignored and the persisted row records the PM.
* [chore] ci-watch/dep-update dedupe: normalize git_url + treat empty-string workflow as default (#148 #1267)
The per-repo open-task dedupe filtered ProjectTable.git_url == git_url
(exact), while the orchestrator collapses its poll set by repo_key
(lower / strip trailing '/' / drop '.git'). Two projects whose git_url
differs only by those accidentals (a monorepo's cell-projects, or a
re-registered canonical project) defeated the one-open-task-per-repo
invariant and opened duplicate fix / dep-update tasks. Extract
roboco.utils.converters.repo_key as the single source and match the
dedupe query on its SQL mirror (regexp_replace(rtrim(lower(...)))).
The ci_watch (git_url, workflow) dedupe used func.coalesce(ci_watch_workflow,
default), but SQL COALESCE only substitutes for NULL — a project saved with
ci_watch_workflow='' (reachable via panel/API) yielded coalesce('', default)
= '' != default, so the DB diverged from the engine/orchestrator (which
collapse '' to the default via Python truthiness) and opened a duplicate
fix task every red cycle. Wrap with func.nullif(..., '') so an empty string
collapses to the default too.
Tests: a ''-workflow + NULL-workflow project on one repo dedupe to one task;
git_url accidentals (.git suffix / trailing slash) dedupe across both
ci_watch and dep_update. The orchestrator _repo_key now delegates to repo_key.
* [chore] admin_set_status: attribute the blocked-restore to the admin actor + emit override row (#2176)
admin_set_status taking a BLOCKED task to pending/in_progress with a
pre-block snapshot returned early via _apply_pre_block_restore, which
emitted its audit row with agent_role=None and audit_agent_id=restored_owner
(the pre-block dev) — the admin actor_id/actor_role were dropped entirely.
Because this branch runs with force=false (pending/in_progress aren't hatch
destinations), the distinguishing task.admin_override row (written only on
the non-restore path, gated by force) was never written, so an operator
could silently re-own a blocked task with no trace of who triggered it.
Thread actor_id/actor_role into _apply_pre_block_restore (admin_set_status
passes them with admin_override=True) so the transition audit row attributes
the re-owning to the admin, and emit a task.admin_override row (forced=False,
restore=True) on this branch independent of the force flag. The in-band
unblock(restore=True) path passes no actor and keeps the legacy attribution
(restored owner) with no override row.
Test: admin PATCH status=pending on a BLOCKED task with a snapshot attributes
every audit row to the admin (not the restored dev) and emits the override
row.
* [chore] converters: typed InvalidIdentifierError from require_uuid + log the orchestrator drop (#25)
require_uuid raised a bare ValueError('UUID value cannot be None'), so a
malformed/None identifier propagated as an opaque error callers either let
500 or broad-catch-and-silently-swallow — the orchestrator reaper call site
wrapped it in a bare except-Exception return with NO log, dropping a bad
task_id_str invisibly. Introduce InvalidIdentifierError(ValueError) and
raise it from require_uuid for both None and unparseable input; it stays a
ValueError subclass so existing except-ValueError / except-Exception callers
are unaffected, but typed so a caller can handle a bad identifier distinctly.
The reaper now catches the typed error, logs at warning, and no-ops — the
drop is visible instead of swallowed.
Tests: None and an unparseable string both raise InvalidIdentifierError; it
subclasses ValueError (back-comat).
* [sweep] notification_delivery: list_system_notifications over-fetch-then-slice for pending_ack_only
The SQL limit was applied before the post-fetch 'not fully acked' Python
filter. A window of newer fully-acked ack-required rows filled the limit
and masked older unacked notifications the operator still needs to act on
(the pending-ACK queue silently under-reported; a CEO-approval notification
could be hidden by newer already-acked noise). pending_ack_only now drops
the SQL limit, filters in Python, then slices to limit; the non-pending
branch keeps the SQL limit unchanged.
* [sweep] proactive: drop vestigial code-patterns surface from context package
Code indexing was removed, so _find_code_patterns always returned [] yet
build_context_package still called it, ContextPackage.code_patterns stayed
a live field, _build_summary advertised 'Found N code patterns', and
_count_items counted it — a permanently-empty slot the system claimed to
populate. The dead method, its call, the summary line, and the count
reference are removed. The code_patterns field itself is retained
(always-empty, serialized in to_dict and the optimal route response) for
API/schema back-compat, marked deprecated in its docstring.
* [sweep] migration 052: integration-test the task_cell_projects unique constraint
The UNIQUE(task_id, team) 'one project per cell per task' invariant was
only exercised through SimpleNamespace stubs that never touch a DB
session, so the real Postgres constraint was unverified. If it were
mis-declared or dropped, two same-team rows could coexist and
_resolve_subtask_project would non-deterministically return one, cutting
a subtask's branch/PR against the wrong repo. Adds an integration test
that inserts two same-(task_id, team) rows and asserts IntegrityError on
uq_task_cell_projects_task_team, plus a positive different-teams case.
* [sweep] pr_gate: classify MegaTask root-subtask as root so its root->master PR gets COMMENT (#608)
_post_gate_review_to_pr identified a root->master PR by absence of a
parent_task_id. A MegaTask root-subtask opens its own root->master PR into
the project's master (submit_root, parent='master') but carries
parent_task_id=umbrella, so is_root was False and the gate posted APPROVE
(pr_pass) / REQUEST_CHANGES (pr_fail) instead of COMMENT. The APPROVE could
satisfy a single-approval master branch-protection rule and let a non-CEO
merge via the GitHub UI before the CEO, against the documented invariant
that only the CEO acts on master. is_root now also covers
is_batch_root_subtask (batch_id set + parented); a non-batch cell-PM
coordination root keeps batch_id=None so it stays a cell->root PR
(APPROVE/REQUEST_CHANGES). Extends the _task test helper with a batch_id
kwarg.
* [sweep] enforcement: complete the status-class partition + coverage invariant (#247)
is_waiting_state already covered awaiting_pr_review (the primary fix), but
the doc's coverage invariant was missing: backlog and pending fell through
ALL three predicates (terminal/active/waiting), so a future enum addition
could silently land in no category. is_waiting_state now also covers
pending (waiting for a claim) and backlog (waiting on PM activation), so
is_terminal_state / is_active_state / is_waiting_state partition the whole
Status enum. Adds test_status_classification_covers_every_enum_member
asserting every Status member is classified by exactly one predicate, so
an enum addition that drifts the partition fails the build.
* [chore] test-suite: unblock the quality gate (mypy + 2 behavior fixes)
12 mypy errors across 5 test files: drop banned type:ignore comments
(lifecycle_spec monkeypatch uses cast(Any, ...); the ignores were unused),
wrap SQLAlchemy-typed ids with cast(UUID, ...) for AgentContext / WorkSession
args (AgentTable.id is Mapped[sqla UUID], not uuid.UUID), annotate **kw: Any,
and cast(Any, svc) for a method-assignment mock.
test_cancel_descendants_cascades_for_authorized_pm: the child was parked in
awaiting_ceo_approval, which the spec gates to CEO-only cancel
(lifecycle.py:378-389) — a cell_pm cascade correctly refuses it (the #103
refuse path). Use a PM-cancelable in_progress child so the positive-cascade
assertion holds; the refuse case is already covered by its sibling test.
test_a2a_message_auth: /message/send now resolves the authenticated
responder slug via get_agent_context (a DB lookup, #116). This is a DB-free
unit test of the token gate + route body, so stub get_agent_context in the
fixture — the gate (require_any_authenticated_agent) still runs real and
401s on a missing/forged token before that dependency resolves.
* [chore] complexity: split 5 C-rank blocks to <=B for the xenon gate
No behavior change; each C-rank function factored into a helper so the
complexity gate (xenon --max-absolute B) holds.
- lifecycle.can_invoke_action: extract the team-match check into
_check_team_match.
- a2a.cancel_task: extract _status_value_of + _apply_cancel_note.
- task._apply_pre_block_restore: extract _restore_block_ownership (status/
owner restore + snapshot clear) and _emit_admin_override_audit (#2176).
- release_proposal.approve: extract _finalize_release_lock (heartbeat/
execute cancel + mutex release) out of the finally.
- kanban.get_main_pm_board_flat: dict-dispatch the column routing instead
of a 7-branch if/elif ladder (status wins over team; in-flight + no cell
team falls through to Coordination, #196).
* [chore] lifecycle artifacts: regenerate to match the spec (foundation-check)
The rendered artifacts (docs/rag/lifecycle, panel/lib/lifecycle.json, the
_generated role-prompt fragments) had drifted from the spec — the prior
sweep commits (cancel-CEO gate, claim_pr_review preconditions, pr_reviewer
unclaim, complete merge-first ordering) changed spec data without
regenerating, and the foundation-check render+diff stage never ran because
mypy failed earlier in the gate. make foundation-check now passes.
* [fix] chat: wire live message delivery end-to-end (MESSAGE_SENT)
send_message persisted messages but never broadcast them, there was no
MESSAGE_SENT event type or bridge forwarder, and the panel session view
had no websocket subscription — the live chat path was dead end-to-end.
- add EventType.MESSAGE_SENT and publish it best-effort on every persisted
send (a bus outage logs, never rolls back the durable row)
- bridge _handle_message_event forwards to /ws/sessions/{id} and
/ws/channels/{id}; subscribe it in register_websocket_bridge_handlers
- panel useSessionStream subscribes the session view; the page invalidates
the transcript + session-detail queries on each message.new so the held
(staleTime Infinity) views refresh live without the manual Refresh
* [fix] chat: return session task_links in one read; drop panel N+1
GET /sessions/{id} ran a bare select and session_to_response omitted
task_links, so it always returned them empty — the panel worked around it
with a triple-fetch (get session, get-tasks-for-session which re-fetched
the same endpoint, then a task GET per link), and the links never showed.
- add get_session_with_links(_or_raise) that eager-loads task_links -> task
- add session_to_response_with_links; GET /sessions/{id} uses both
- panel useSession now relies on the single populated response; remove the
dead getTasksForSession + per-task fetch and the unused tasksApi import
* [fix] chat: validate reply_to against the effective session; guard closed-session composer
Posting to a closed session transparently redirects the message to the
group's active session (intended for agents holding stale refs), but
reply_to was validated against the requested session, not the one the
message lands in — letting a cross-session reply slip through — and the
panel silently posted there too, so the message vanished from the view.
- validate reply_to against session.id (the effective, possibly-redirected
session), not req.session_id
- panel: render a "session is closed" notice instead of the composer for a
non-active session; if a send still lands elsewhere (stale status), toast
that it went to the active session rather than letting it appear to vanish
* [fix] chat: close session/group/message read IDOR; fix doubled 404s
get_session and the messages-list took an agent id but never used it, and
get_group took none at all — any authenticated agent could read any private
channel's group, session, and message transcripts. Three NotFoundError sites
also passed a full sentence as resource_type, yielding "... not found not found".
- add require_group_read_access / require_session_read_access (channel
member / silent observer / privileged, mirroring list_group_sessions_for_agent)
and get_session_with_links_for_agent; enforce on GET /sessions/{id},
GET /sessions/{id}/tasks, GET /messages, GET /groups/{id} (-> 403 on deny)
- fix the three doubled-404 sites to the NotFoundError(resource_type, resource_id) form
Also folds two gate fixes for the prior chat commits: cast session.id to UUID
for the reply_to validation, and ruff import/format touch-ups.
Note: POST /messages intentionally still skips the channel write-ACL on the
HTTP (human-CEO/panel) path — the CEO is not in writers for 8/11 channels, so
enforcing it there would block the panel; the gateway/agent path enforces it.
* [fix] secretary: harden live chat — stuck spinner, mid-reply clobber, reload
The Secretary live chat had three live-behaviour bugs: a dropped SSE
connection left a permanent "thinking…" spinner (openStream set no
transport-error handler, so the no-data error Event was swallowed by the
JSON-parse guard and streaming never reset); sending mid-reply wiped the
accumulation buffer and pushed a user message without guarding the in-flight
turn, abandoning/duplicating the reply; and the chat lived only in React
state, so a reload wiped it.
- route the dual-purpose `error` listener: server-sent JSON → handleEvent,
transport error (no data) → reset streaming, surface a notice, close stream
- guard send while streaming (streamingRef); disable the composer Send/Enter
while a reply is in flight
- persist sessionId + messages to localStorage (TTL'd) and, on mount, restore
+ re-attach the stream once the backend confirms the session is still alive
(mirrors the intake/prompter durability)
* [chore] groups: extract group-read helper to keep module rank A
The get_group IDOR access-check added try/except branches that tipped the
module to xenon rank B. Extract the service-error→HTTP mapping into a small
helper so get_group stays lean and the module is rank A again (behaviour
unchanged; covered by the groups route tests).
* [fix] chat: correct panel session-task mutation endpoints
linkTask/unlinkTask posted to /add-task and /remove-task (with a body), but
the backend exposes POST /sessions/{id}/tasks and DELETE
/sessions/{id}/tasks/{task_id} (path param) — so every call 404'd. updateTaskLink
targeted /update-task, a route that does not exist at all. Point linkTask and
unlinkTask at the real routes and drop the phantom updateTaskLink. All three were
unused, so no behaviour changes today — this removes a latent 404 trap.
* [docs] chat: document live message delivery (MESSAGE_SENT / message.new)
Document the live transcript-update path the chat-subsystem fixes wired:
- docs/api/websockets.md: add the message.new event-types row (carried on
/ws/sessions + /ws/channels from EventType.MESSAGE_SENT) and note the
forwarder sets type:"message.new"
- docs/panel/communications-and-journals.md: the session transcript updates
live; a closed session is read-only (composer disabled)
- CLAUDE.md: name message.new on the per-resource streams and make
MESSAGE_SENT the worked example of the add-a-live-event recipe
The internal roboco_map slices (gitignored) were updated in place to match.
* [docs] reconcile published docs with code since v0.13.0
Drift caught by the doc-reconciliation pass (all verified against HEAD):
- CLAUDE.md + rag: pr_reviewer gained the unclaim verb (
|
||
|
|
4c85cc6dfa |
feat(content): anti-soup guard on the flow verbs' free-text
Extends structured-content enforcement from the content tools to every flow verb that carries agent free-text, closing the last hole where a dev/PM could pass word soup: i_am_blocked(reason), i_am_done(notes), submit_up/submit_root/complete(notes), escalate_up/escalate_to_ceo(reason), pass_review(notes), fail_review/pr_fail(issues), pr_pass(notes), i_documented(notes), delegate(title/description). Plans (i_will_plan / i_will_work_on) keep their existing >=150-char approach + sub_task gates and are skipped here so recovery re-entry with thin values still works. Shared helpers on the choreographer: _free_text_soup (bare envelope, list aware) and _soup_or_decision_env (folds the soup check into a verb's existing spec-gate return so no verb gains a return or tips the xenon bound). reject_trivial now also catches all-filler multi-token strings. base.md documents the broadened rule for agents. |
||
|
|
b9082a5c70 |
Fix: agent idle deadlock and lifecycle hardening (#96)
* fix(panel): cap dialog height and pin footer so actions stay reachable
Shared DialogContent now caps at max-h-[85vh] with overflow-y-auto, and the
footer is sticky to the bottom. Long content (e.g. a pasted change-request
note) no longer pushes the submit/cancel buttons past the viewport — the body
scrolls while the actions stay visible. No-op on dialogs that already fit.
* feat(notifications): suppress duplicate same-purpose notifications at send
A notification is not created when an unacknowledged one with the same purpose
— same sender, same type, same task, overlapping recipients — already exists.
Body text is not compared, so rewording cannot defeat it; a different type,
task, sender, or an already-acked recipient all still send through. Stops
agents that loop re-issuing the same signal from piling up unread that
soft-blocks the recipient's idle path.
* fix(gateway): stop board/PM lifecycle verbs from 500-crashing
Two unguarded crashes that wedged the org in respawn/escalate loops:
- escalate_to_ceo dereferenced None.status when the verb runner declined the
escalation (task not in awaiting_pm_review — e.g. a board agent escalating a
blocked task). It now returns a clean invalid_state. The message/remediate
build moved to a helper so the function stays within the complexity gate.
- The coordination-root git ops (pr_target, pr_merge, PR update, branch-token
resolve) called UUID(str(task.project_id)) directly, which raised on a
coordination/integration task (project_id is None — 'badly formed hexadecimal
UUID string'). They now resolve through _project_for_task, which falls back to
the product's repo for project-less roots.
* refactor(intake): split out _block_to_chunk per-block classifier
Extract the per-block classification from _blocks_to_chunks so each function
stays within the xenon cyclomatic-complexity gate (was rank C). Behaviour is
unchanged — verified by the existing intake_driver tests.
* feat(gateway): make the i_am_idle unread soft-block satisfiable
The soft-block on unread A2A / @mentions had no clearing path, so once those
briefing fields populated an agent could never idle — a whole-org deadlock.
Keep the guard (it is correct) and add the missing clear paths:
- New read_messages content verb (schema -> route -> handler ->
a2a.mark_all_read -> MCP tool -> role do_tools): bulk-zeroes the caller's
unread A2A and stamps read_at. The idle hint now points to it.
- list_unread_mentions returns UNACKED MENTION-type notifications (each @mention
already raises one via messaging._notify_mentions) instead of raw,
unconditional mentions, so they clear via the existing notify_ack. No schema
migration needed.
The soft-block is now satisfiable: A2A via read_messages, mentions and
notifications via notify_ack.
* fix(tests): repair notification-dedup db.scalar mocks + prompter agent seeding
The notification send-dedup added a db.scalar() purpose-lookup to
_create_notification; the two hand-rolled _FakeDb test stubs (test_notification,
test_a2a_priority_tristate) had no scalar() method → AttributeError. Add
scalar() returning None (no duplicate) so creation proceeds.
Separately, the prompter '& Start' route tests assign the draft to a fixed
product-owner / main-pm AGENT_UUID but only seeded system + CEO, so the
assigned_to FK failed in isolation (and main-pm flaked in the full suite). Seed
both via idempotent merge() in _seed_project_and_ceo.
* fix(git): gitignore .pnpm-store + flag GH001 push rejection as permanent
A dev once committed the ~115 MB pnpm store → GitHub GH001 (>100 MB) pre-receive
reject → open_pr retry-loop. Two root fixes:
- Add .pnpm-store/ to .gitignore — an ignored dir can't be staged by any git add.
- push() restates a GH001 / file-size rejection as an unmistakable PERMANENT
error pointing at i_am_blocked, so the agent stops blind-retrying a push that
can never succeed (it otherwise mis-reads the raw output as a transient timeout).
The per-verb retry cap (open_pr: 5) already bounded the burn; this ends it.
* fix(gateway): accept a PM decision note as satisfying the complete/submit_up reflect gate
A cell/main PM that wrote a fresh decision but no separate reflect note bounced
on the reflect tracing-gate indefinitely (re-confirmed live: cell PMs looped on
cell_pm_complete -> journal:reflect until reaped, burning tokens — worse because
each respawn resets the per-verb retry cap). For a PM closing/submitting a task
the decision note already documents the close; the separate reflect is the
redundant artifact weak-model PMs forget. Accept a fresh decision as satisfying
reflect for complete + submit_up — the gate still requires a decision +
substantive notes, so the close stays documented.
NOTE (enforcement tradeoff, flagged for CEO review): this intentionally relaxes
the PM complete/submit_up gate. It does NOT touch the developer i_am_done gate.
* feat(gateway): refuse i_am_idle when a PM still owns a task awaiting its review
A cell/main PM once tried to 'send work back' by DMing the developer and going
idle — but a DM changes no task state, so the task stayed awaiting_pm_review and
the orchestrator just re-dispatched the PM in a loop. i_am_idle now refuses (like
the pending-assignment guard) when a PM owns an awaiting_pm_review task, with a
clear remediation: complete() to finish, or reassign()/delegate() to route it
back. PM-only; devs/QA/doc unaffected. Pairs with the reflect-gate relaxation so
the PM can actually complete instead of looping.
* feat(gateway): push a prior-work handoff digest into task-scoped briefings
A freshly spawned or respawned agent previously started cold on every
lifecycle hand-off: the prior worker's PR, commits, acceptance status and
journal highlights lived in task evidence but were pull-on-demand, so each
new role agent re-explored the codebase from scratch — wasted tokens and
fragile context loss across respawns.
build_task_handoff() composes a compact, DB-only digest (no git diff) and
_briefing_for() now attaches it to context_briefing whenever the caller
already holds the task row. The digest is built only from a passed-in task,
so there are zero extra fetches: every resumption entry point (give_me_work
and pm_give_me_work, i_will_work_on, i_will_plan, triage/triage_all,
i_am_done, submit_up, escalate_up, complete) threads the loaded task, while
id-only correction/rejection paths cleanly omit it.
Every field is type-guarded so a partial row never leaks a non-serialisable
value into the envelope.
* docs(prompts): tell agents to resume from the briefing handoff before re-exploring
The base prompt described the success envelope but never told agents to act
on context_briefing, so a respawned or hand-off agent would re-scan the
whole repo and re-derive the plan even when the briefing already carried the
prior worker's PR, commits, acceptance status and journal highlights.
Adds a 'Resume from your briefing' section that walks each task_handoff
field and instructs the agent to continue from it — and to read the unread
A2A / mention / notification lists, which are messages addressed to them.
Pairs with the gateway change that now pushes task_handoff into every
task-scoped briefing.
* feat(tasks): remember cleared dependencies so the unblock briefing can surface them
When an upstream dependency completed, _unblock_dependents removed its id from
the dependent's dependency_ids to let it be claimed — destroying the only
record of which upstream task had just landed. The revived dependent then
re-discovered that work from cold.
Adds tasks.completed_dependency_ids (Alembic 026, uuid[] default '{}'):
_unblock_dependents now appends the cleared id there instead of only dropping
it, and the briefing handoff digest surfaces it so the agent picking the task
back up knows its blocker cleared because that upstream work shipped. The base
prompt documents the field.
Migration round-trip verified against postgres (upgrade adds the column,
downgrade drops it).
* docs(prompts): instruct PMs to split oversized tasks into per-concern subtasks
A subtask carrying a long acceptance list or spanning multiple layers/files
drove repeated QA failures and a PM revision loop — QA can't pass a partial,
and the dev keeps re-touching unrelated parts. Nothing in the PM prompts told
them to decompose by size/concern.
cell_pm gets a 'Sizing' rule: one subtask = one focused concern with ~2-4
criteria and its own dev->QA pass; decompose anything larger before
delegating, sequencing with dependencies. main_pm gets a matching reminder to
scope each cell's slice to that cell's layer rather than handing a cell a
cross-layer monolith that just pushes the problem down a level.
* fix(gateway): mirror the task= kwarg on ChoreographerHelpers helper signatures
The handoff-digest change added a keyword-only task= parameter to
_briefing_for and _build_tracing_gap in _impl, but the ChoreographerHelpers
base that the role mixins inherit still declared the old signatures, so the
composed Choreographer had two incompatible base definitions (mypy [misc]).
Sync the base declarations to match.
* fix(tasks): keep the owner on a substitute-out so the task isn't orphaned
build_substitute_update unconditionally nulled assigned_to, so any
substitute that routes to PENDING (max_retries, low_context, out_of_scope_*)
— the path a verb hitting repeated 500s or its retry limit takes — left the
task pending AND unassigned. The dispatcher only respawns a pending task when
it has an owner, so the task went dormant: no agent ever picked it back up.
Keep the task with its current owner instead. A substitute-out is almost
always a transient stall, so the task re-dispatches to the SAME agent, which
resumes from the briefing handoff. Only the task_complete -> PM-review handoff
changes owner (unchanged).
* feat(a2a): suppress duplicate unread A2A messages at send
A respawned or retrying agent could re-emit the same DM, stacking identical
copies on the recipient's inbox and re-bumping the unread count — noise that
the recipient then has to clear. The notification path already dedups; A2A did
not.
send_chat_message now suppresses a send when an identical message from the
same sender is still unread in the conversation, keyed on (conversation,
sender, message_kind, content). Genuinely different messages are never
collapsed (verified: distinct content still produces distinct rows), so this
avoids the earlier per-pair over-suppression. No migration.
* fix(panel): default the notifications view to Unread, not All
Landing on the All tab buried new notifications under everything already
seen — the most-reported annoyance. The Unread tab is the actionable view, so
make it the default; the All/Pending tabs are one click away.
* fix(panel): show clone progress during intake prep instead of a frozen pill
The first clone of a repo can take a few minutes, during which the intake
form showed only a static 'Preparing the agent…' button — indistinguishable
from a hang. Add a progress region while preparing: an elapsed timer, a
saturating progress bar (approaches but never reaches 100% until the agent
actually answers), and staged copy (spinning up → cloning → first-clone-takes-
a-while → reading the codebase) so the wait reads as work, not a freeze.
* feat(docs): index workspace-authored docs that never reached the RAG store
Docs written through roboco_docs_write land at /app/docs on the orchestrator
and index fine. But a documenter can also write docs with Edit/Write directly
in its own clone (README, CHANGELOG, workspace markdown); those resolve to a
/app/docs path that doesn't exist on the orchestrator, so the indexer reads
nothing and the docs never become searchable — a cross-container miss with no
shared mount to bridge it.
On docs completion, capture each listed doc's committed content out of the
branch (new GitService.read_file_at_branch, via git show) and write it
server-side under /app/docs before indexing, so workspace-authored docs reach
RAG too. Docs already present server-side are skipped; absolute paths and
unreadable/uncommitted files are passed over best-effort.
* feat(prompter): survive a browser reload by reconnecting to the live intake chat
The intake chat lived entirely in React state, so a page reload wiped it and
dropped the human back to the scope form — even though the agent container
outlives the page. Now the chat persists a small TTL'd slice (session id,
messages, scope, draft) to localStorage and, on mount, reconnects: it asks the
new GET /live/{id}/status whether the session is still running and, if so,
restores the history and reopens the SSE stream; if dead or expired it clears
and shows the form. A full reload doesn't run React effect cleanup, so the
navigate-away reap never fires on refresh and the session stays up.
Backend adds the status endpoint + PrompterLiveRegistry.is_alive; localStorage
is cleared on confirm, start-another, and SPA navigate-away.
* chore: remove internal session-bookkeeping refs from code comments (part 1)
Strip leaked task/finding numbers, Wave/Phase/cluster/audit labels from
docstrings and comments across services, foundation policy, runtime, mcp,
api schemas, and agent_sdk — they mean nothing to a repo reader and expose
process internals. Wording preserved; only the labels dropped. Done by hand,
one comment at a time (no scripted rewrite). _impl.py follows separately.
* chore: remove internal session-bookkeeping refs from code comments (part 2)
Finishes the manual scrub: the choreographer _impl.py docstrings/comments plus
the remaining dogfood-run ('smoke-N') labels across runtime, mcp, foundation,
api schemas, services, and agent factories. Reworded to describe the bug or
behaviour in plain words; every label dropped. The repo source is now free of
task/finding numbers, Wave/Phase/cluster/audit/smoke labels. By hand, one
comment at a time.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
|
||
|
|
41ef7f6b4e |
feat(gateway): C8 PM-decision gate windowed satisfaction
_check_pm_decision_required now requires the latest journal:decision within pm_decision_window_seconds (default 300). Older decisions no longer satisfy the gate. Adds JournalService.latest_decision_at. Future-tighten (out of scope): per-verb-group consumption tracking would need persistent state — Choreographer is per-request today. |
||
|
|
207aaecd72 |
Feature: lifecycle canonical spec (#14)
* chore: clean make quality baseline on feature/lifecycle-canonical-spec
Three classes of pre-existing issues blocking `make quality`:
1. Alembic migrations 002/009/011 used runtime introspection
(op.get_bind() + inspect / bind.execute) without guarding for
offline (--sql) mode. `alembic upgrade head --sql` is part of
`make quality`; in offline mode `op.get_bind()` returns a
MockConnection with no inspection system, so the migrations
crashed before emitting their SQL stubs. Each migration now
short-circuits or simplifies in `context.is_offline_mode()` —
live-DB behavior is unchanged.
2. ruff format drift on three files left over from prior in-flight
edits (choreographer/_impl.py, content_actions.py, and one test
file). `ruff format` applied.
3. vulture flagged two unused `tb` parameters in async __aexit__
stubs in test_task_service_lifecycle_misc.py. The parameter is
protocol-required but unused by the body — renamed to `_tb`
(vulture treats underscore-prefixed names as intentionally unused).
`make quality` is now green from this branch's HEAD; subsequent
lifecycle-spec work can use it as the per-task gate.
* feat(lifecycle): canonical spec package + Role/Status/TaskType enums
Foundation for the canonical lifecycle/permissions module. Enums
mirror docs/internal/old/workflows/STATUS_TRANSITIONS.md +
PERMISSIONS.md. Tests pin enum membership against both the
predecessor canon and roboco.models.base.TaskType.
* feat(lifecycle): Decision dataclass with allow/reject/tracing_gap constructors
Single rejection shape every consumer maps to its native format
(Envelope, HTTP code, prompt hint). __post_init__ enforces the
allowed/rejection_kind invariants so a malformed Decision can't reach
a consumer.
* fix(lifecycle): tighten Decision invariants per Task 2 review
Two reviewer findings on the Task 2 Decision dataclass, addressed
in one commit:
1. The docstring promised `allowed=True ⇒ rejection_kind is None
AND missing == [] AND remediate is None`, but __post_init__ only
checked the rejection_kind half. A caller could construct an
allow-shaped Decision with stale missing/remediate fields and
sneak it past validation. Tighten __post_init__ to enforce the
full invariant. Add a regression test.
2. tracing_gap defensively copies the missing list (`list(missing)`)
to isolate the stored list from later caller-side mutation, but
no test pinned this. Add a regression test that mutates the source
list after construction and asserts the stored list is unchanged.
Issue 2 from the same review (mutable list vs tuple for `missing`)
is a broader design call deferred until consumers exist; the
defensive copy is sufficient until then.
* feat(lifecycle): Precondition/ActionSpec/IntentSpec/StatusTransition dataclasses
The four dataclasses that hold the canonical tables. ActionSpec and
StatusTransition are direct ports of pre-gateway PERMISSIONS.md +
STATUS_TRANSITIONS.md rows. IntentSpec is the gateway-only addition:
each gateway intent verb declares which atomic actions it composes.
* feat(lifecycle): _STATUS_TRANSITIONS table + STATUS_GRAPH view
Direct port of STATUS_TRANSITIONS.md. Every transition records its
trigger action and (optionally) a role constraint. STATUS_GRAPH is
the precomputed source→{targets} view callers use for reachability
checks.
* fix(lifecycle): pin role_constraint values + clarify Task-5 handoff
Two reviewer findings on Task 4 _STATUS_TRANSITIONS, addressed in
one commit:
1. The original Task-4 tests verified (source, target) pairs but
not role_constraint contents. A typo in a single role name (e.g.
forgetting MAIN_PM from escalate_to_ceo) would have slipped past
them silently. Add test_status_transitions_role_constraints_match_canon
pinning every non-None constraint and the cancel-block invariant.
2. role_constraint=None on the `claim` rows from PENDING and
NEEDS_REVISION was load-bearing — it is the explicit handoff
point between the StatusTransition table (state machine layer)
and CLAIM_RULES (per-role claim authority, lands in Task 5).
The original inline comment said this in passing; expand it so
the design choice is unmissable for a stranger reading just
spec.py.
* feat(lifecycle): _ATOMIC_ACTIONS + CLAIM_RULES + ROLE_TEAM_RULES tables
Direct port of PERMISSIONS.md. Every task management tool gets an
ActionSpec with allowed_roles, source_statuses, target_status,
self_review_block, and needs_team_match flags. CLAIM_RULES maps each
Role to the statuses they can claim from. ROLE_TEAM_RULES is the
per-slug team restriction.
* fix(lifecycle): tighten ActionSpec contracts per Task 5 review
Three reviewer findings on Task 5's _ATOMIC_ACTIONS table, addressed
in one commit:
1. set_plan.source_statuses widened to {CLAIMED, IN_PROGRESS} but
every existing caller (i_will_work_on / i_will_plan compositions)
runs set_plan while CLAIMED, between claim and start. Narrow to
{CLAIMED} only. If a future "edit plan mid-flight" feature lands,
widen explicitly with test coverage at that time.
2. needs_team_match was set True only on claim/qa_pass/qa_fail/
docs_complete. Defense-in-depth says every role-scoped task
action should re-assert team match (don't rely on the inheritance
chain through assigned_to alone). Flip to True on: start,
set_plan, block, pause, submit_verification, submit_qa,
submit_pm_review, complete, create_subtask. Leave False on
board/CEO actions and PM cross-cell interventions (unblock,
resume, cancel) where the cross-cell semantics are intentional.
3. claim.source_statuses is intentionally a SUPERSET of any single
role's CLAIM_RULES allowance (the table holds the union; CLAIM_RULES
holds the per-role authority). Add an inline comment above the
claim ActionSpec so a future reader doesn't conclude the two
tables disagree — they don't, they encode overlapping facts at
different grains.
* feat(lifecycle): _INTENT_VERBS table — every gateway verb declared
Each gateway intent verb is now a named composition of atomic actions
plus optional side effects. i_will_work_on = (claim, set_plan, start);
i_am_done = (submit_verification, submit_qa); open_pr is pure side
effects (push_branch, create_pr); etc.
* fix(lifecycle): widen block.allowed_roles to include QA + Documenter
Task 6 review caught a role-set inconsistency: i_am_blocked.allowed_roles
admits dev/QA/doc, but the underlying block.allowed_roles only allowed
dev+PM. Result: a QA or documenter calling i_am_blocked would pass the
IntentSpec gate and then be rejected by the composed ActionSpec gate
when Task 7 wires can_invoke_intent.
Widen block to include QA + Documenter. The semantic case is sound: a
QA reviewing a task can discover an external blocker; a documenter
writing docs may need PM intervention. Predecessor PERMISSIONS.md
restricted block to dev+PM, but with the gateway exposing i_am_blocked
to all worker roles, the underlying atomic must agree.
The deeper unclaim/escalate_up "imperative verb" concern from the same
review (composes=() but mutates state) is deferred to Task 8 where the
validator design lands.
* feat(lifecycle): public lookup functions + Context + preconditions
can_claim, can_invoke_action, can_invoke_intent, valid_next_verbs,
composed_actions_for, intents_for_role, status_after — the entire
public surface every consumer will use. Context carries the
caller-supplied state preconditions need (plan, journal-decision
flag, etc.). Preconditions for plan/commits/no_pr/ownership are
declared once and wired into the relevant IntentSpecs.
* fix(lifecycle): wire PRECONDITION_OWNERSHIP through Context.actor_id
Task 7 review found _p_owns_task reads agent.id but every call site
passes None for the agent arg. Result: getattr(None, "id", object())
returns a fresh sentinel, task.assigned_to == <sentinel> is always
False, and open_pr / i_am_done would reject every owner the moment
Task 9 wires consumers.
Fix: thread identity through Context.actor_id (new UUID field) and
rewrite _p_owns_task to read from the context. Both call sites already
pass the Context — no signature changes elsewhere. Add green-path
test exercising the owner-can-open-pr case the existing tests
missed (the Task 7 plan only tested precondition-failure paths,
which masked the bug).
Plus surface hygiene: STATUS_GRAPH, CLAIM_RULES, ROLE_TEAM_RULES,
and the four PRECONDITION_* constants are now in
roboco.lifecycle.__init__.__all__ so consumers in Tasks 8/9 don't
depend on the implicit `from roboco.lifecycle.spec import ...`
backdoor.
* feat(lifecycle): import-time self-consistency validators
10 validators run at module import; first failure raises
LifecycleSpecError and prevents the package from loading. Covers
status enum coverage, reachability, terminal exits, intent
compositions, status chain consistency, claim-rule role/status
coverage, self-review symmetry, team-rule slug existence, and
StatusTransition action references.
* fix(lifecycle): close validator gaps; resolve BACKLOG-claim and submit_qa IN_PROGRESS-shortcut ambiguity
Three reviewer follow-ups on Task 8's _validate.py, plus two real
data corrections the new action-target-reachability validator
surfaced.
1. Design spec §9 calls for "every ActionSpec.target_status, when
set, is reachable from each source_status via STATUS_GRAPH" —
missing from Task 8's 10 validators. Add
_check_action_target_reachable_from_source.
2. _check_role_team_rules_slugs verified slug existence in
AGENT_UUIDS but NOT that the cell team in ROLE_TEAM_RULES
matches the seed. Add _check_role_team_rules_team_match,
scoped to non-None entries only — None means "exempt from
team-match enforcement" (cross-cell roles), not "no team in
org chart".
3. test_validators_pass_on_real_spec was ceremonial. Add
test_run_all_validators_raises_on_unknown_intent_action,
a deliberate-break regression that monkeypatches _INTENT_VERBS
to inject a fake action and asserts LifecycleSpecError raises.
The new action-target-reachability validator caught two real
data inconsistencies between the predecessor canon docs and the
spec tables:
A. claim.source_statuses listed BACKLOG and CLAIM_RULES[*PM]
listed BACKLOG, but STATUS_GRAPH[BACKLOG] = {PENDING, CANCELLED}
only. Resolution: PMs use the explicit \`activate\` action to
move BACKLOG → PENDING, then claim from PENDING. Drop BACKLOG
from claim.source_statuses and CLAIM_RULES.
B. submit_qa.source_statuses listed IN_PROGRESS, but
STATUS_GRAPH[IN_PROGRESS] does NOT include AWAITING_QA. The
intent verb i_am_done composes (submit_verification, submit_qa)
which forces IN_PROGRESS → VERIFYING → AWAITING_QA — no
shortcut. Drop the stale IN_PROGRESS entry from
submit_qa.source_statuses.
Both corrections tighten the canonical state machine to a strict
no-skip transition graph. Pre-gateway PERMISSIONS.md/STATUS_TRANSITIONS.md
disagreements are resolved here; spec.py is the canon now.
* feat(gateway): Envelope.from_decision maps lifecycle Decisions to envelopes
Single shape adapter so verb bodies stop hand-composing rejection
envelopes. Each rejection_kind maps to a specific envelope flavor;
'self_review' folds into 'not_authorized' with a parenthetical hint;
constructing from an allow Decision raises (programmer error).
* feat(gateway): VerbRunner for atomic composed-action dispatch
Wraps spec.composed_actions_for(intent) in session.begin_nested()
so mid-sequence failures roll the DB back. Side effects run AFTER
the savepoint commits. Each atomic action name dispatches to a
TaskService method via a single, exhaustive _dispatch_atomic
mapping. New verbs slot in by adding an IntentSpec entry + a
_dispatch_atomic case if a new atomic is needed.
* refactor(gateway): i_will_work_on uses spec.can_invoke_intent + VerbRunner
Replace the bespoke status-branch dispatcher in i_will_work_on with the
spec-driven flow: load task -> load agent -> build spec.Context ->
spec.can_invoke_intent (and spec.can_claim for per-role status authority)
-> Envelope.from_decision on rejection -> VerbRunner.run_intent on success.
The _i_will_work_on_pending, _i_will_work_on_claimed,
_i_will_work_on_needs_revision, and _start_failed_envelope helpers are
removed; the runner replaces them. Two narrow verb-body re-entry blocks
remain for behaviors the spec does not yet model:
1. in_progress + same agent -> idempotent heartbeat-only return
2. claimed + same agent -> _resume_from_claimed (set_plan + start)
to recover from a stuck mid-claim crash without re-running claim
against a state the spec excludes.
The behavioral claim guards (already_active / paused / sibling_sequence)
also stay imperative for now -- they're not in the spec yet and migrate
into spec.extra_preconditions in a later task. Per-role claim authority
is enforced via spec.can_claim because the atomic claim action's
source_statuses are the union across roles; CLAIM_RULES narrows.
Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against every (role x status x task_type='code') combo (112 rows) and
asserts the envelope error matches the spec's Decision (or can_claim's
Decision when the intent gate passes but per-role claim authority does
not). This is the contract that makes spec/verb drift impossible.
Existing tests updated where rejection-message text changed (the spec
now produces the messages, e.g. "role 'cell_pm' may not call
'i_will_work_on'" instead of "PM cannot execute code") or where the
spec's stricter view ("invalid_state" -> "not_authorized" for a dev
trying to claim awaiting_qa) is more accurate. Test fixtures were
updated to wire task.session.begin_nested as a proper async context
manager (required by VerbRunner) and to set agent_for().id so runner-
driven calls line up with assert_awaited_with(task_id, agent_id).
* refactor(lifecycle): push CLAIM_RULES enforcement into can_invoke_action
Task 11's i_will_work_on migration had to call spec.can_claim()
separately after spec.can_invoke_intent() because the claim action's
source_statuses is the union across all claim-eligible roles —
can_invoke_intent alone would let a developer pass for claiming
awaiting_qa (a QA-only state).
The retrofit pattern would repeat in every claim-composing verb
(i_will_plan, claim_review, claim_doc_task). Push the per-role
narrowing inside can_invoke_action when the action is "claim",
using the same not_authorized vs invalid_state disambiguation
can_claim already implemented (status-reserved-for-another-role
returns not_authorized; status-no-role-can-claim returns
invalid_state). Extracted the body to _check_claim_rules_narrow
to keep can_invoke_action under xenon's complexity threshold.
Update _i_will_work_on_gate to drop the redundant spec.can_claim
call. Update test_consumer_parity.py to assert only against
can_invoke_intent's Decision.
Tasks 12-22 will inherit the cleaner pattern: spec.can_invoke_intent
is the single gate; verb bodies don't need per-action retrofits.
* refactor(gateway): i_will_plan uses spec.can_invoke_intent + VerbRunner
Migrates i_will_plan to the spec-driven pattern Task 11 set up for
i_will_work_on. The verb body now: (1) loads task + agent, (2) builds
Context, (3) checks idempotent/recovery re-entry, (4) calls
spec.can_invoke_intent, (5) returns Envelope.from_decision on
rejection, (6) delegates composition to VerbRunner. The
_i_will_plan_* helpers are removed — the runner replaces them.
Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against every (role × status × task_type) combo and asserts the
envelope matches spec.Decision.
* refactor(gateway): delegate uses spec.can_invoke_intent for role/state gate
Migrates delegate to the spec-driven role/state gate. The chain
validation (main_pm->cell_pm, cell_pm->its team's devs), the
assignee-vs-task_type rule (Cell PMs receive planning-typed only),
the enum coercion, and the parent-lifecycle/cap guards STAY in the
verb body — they encode delegate-specific semantics the spec
doesn't model.
Parity test in tests/lifecycle/test_consumer_parity.py asserts the
spec's role+state rejection is correctly surfaced. Chain/assignee
rejections continue to be tested in test_choreographer_pm_extras.
* refactor(gateway): open_pr uses spec.can_invoke_intent + VerbRunner
Migrates open_pr to spec-driven gating. The spec's
extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS,
PRECONDITION_NO_PR) handle all three precondition checks; the verb
body delegates side-effect dispatch (push_branch, create_pr) to
VerbRunner.
Idempotent re-entry retained: an open_pr call against a task that
already has a PR (and the caller owns it) returns OK without
re-opening, rather than the tracing_gap the spec would otherwise
produce. This preserves agent ergonomics — two calls in a row
shouldn't surface a misleading "no_prior_pr" hint.
Parity test in tests/lifecycle/test_consumer_parity.py runs the verb
against representative (status x commits x pr_number) combos and
asserts the envelope matches spec.Decision.
* refactor(gateway): i_am_done uses spec.can_invoke_intent + VerbRunner
Migrates i_am_done to spec-driven gating. The spec's
extra_preconditions (PRECONDITION_OWNERSHIP, PRECONDITION_COMMITS)
handle ownership and commit-count checks; VerbRunner dispatches
the (submit_verification, submit_qa) atomic chain.
The tracing-gate preconditions (progress entry, journal:reflect,
acceptance criteria) and the field-level submit-qa gates stay in
the verb body — they model gates the spec doesn't yet cover.
Defense-in-depth: those gates run after the spec accepts the
ownership/commits checks.
Parity test in tests/lifecycle/test_consumer_parity.py runs the
verb against (role × status × ownership × commits) and asserts
the envelope matches spec.Decision.
* refactor(gateway): i_am_blocked uses spec.can_invoke_intent + VerbRunner
Migrates i_am_blocked to spec-driven gating. The journal:struggle
write stays in the verb body (it's a side effect outside the
lifecycle action). VerbRunner dispatches the `block` atomic action
via task_service.escalate.
Parity test in tests/lifecycle/test_consumer_parity.py.
* refactor(gateway): unclaim and resume use spec.can_invoke_intent
Migrates both verbs to the spec-driven gate. unclaim's verb body
keeps its dispatch (task.unclaim_for_agent) because composes=();
resume goes through VerbRunner with composes=("resume",).
The reassignment-rejection branch (introduced in
|
||
|
|
62bda0c497 |
Gateway/full (#9)
* chore(gateway): scaffold gateway package and test layout
* feat(config): add gateway feature flags, coordination thresholds, commit-validator settings
* feat(gateway): add standardized response envelope with ok/error variants
* feat(gateway): add remediation hint catalog for tracing-gap and invalid-state errors
* feat(gateway): add per-role flow/do tool catalog with developer, qa, doc, pm, board configs
* feat(db): add gateway columns — active_claimant_id, heartbeat, pre_block snapshot, acceptance_criteria_status, qa_evidence_inspected
* feat(db): create gateway_triggers table for dispatcher decision logging
* feat(db): align canonical skill set; substitute qa_review -> code_review across agent seeds
* fix(db/008): make skill alignment in-place + idempotent; preserve column and existing custom skills
* feat(gateway): add claimant_lock for single-active-agent invariant with heartbeat staleness
* feat(gateway): add trigger_filter with stale-cleanup, claimant-queue, and cooldown rules
* feat(gateway): add tracing_gate with plan, progress, journal, acceptance_criteria, qa requirements
* refactor(gateway): drop per-file ruff ignores; refactor tracing_gate with dispatch table + GateContext
* feat(gateway): add merge_chain to resolve PR target by branch hierarchy depth
* feat(gateway): add commit_validator with min-length, banned-words, and conventional-shape hints
* feat(gateway): add evidence_builder for verb-response evidence and capped context_briefing
* feat(gateway): add Choreographer skeleton with per-phase verb signatures and DI protocols
* feat(runtime): add spawn_manifest builder for per-role pre-loaded tool registration
Introduces SpawnInputs dataclass + build_for_role(inputs) + write_manifest()
in roboco/runtime/spawn_manifest.py; reads role_config for allowed verbs/tools,
emits JSON manifest that SDK shim reads at container startup to eliminate ToolSearch.
* feat(runtime): wire gateway pre-spawn check (trigger_filter + claimant_lock) into orchestrator behind ROBOCO_GATEWAY_ENABLED flag
- Add GatewayTriggerTable SQLAlchemy ORM model to roboco/db/tables.py
(matches existing table from migration 007_gateway_triggers_table)
- Add module-level gateway_pre_spawn_check() + helpers to orchestrator.py
(gated: returns ("spawn", "gateway disabled") immediately when flag is False)
- Wire gateway check into _safe_spawn() — the single dispatcher choke-point
for all agent spawns; QUEUE or DROP outcome logs and returns None (no spawn)
- ROBOCO_GATEWAY_ENABLED defaults to False; legacy behaviour is unchanged
* feat(agent_sdk): load tool-manifest.json at startup behind ROBOCO_GATEWAY_ENABLED flag (no agent-visible change yet)
Adds load_tool_manifest() to the SDK server that reads env at call-time
so gateway-enabled agents can obtain their pre-registered tool list at
startup; returns None when the flag is off, leaving the legacy briefing
path completely unchanged.
* fix(optimal_brain): skip indexing when source ID is None to eliminate roboco://journals/None spam
- Add `build_doc_source(kind, id_)` module-level helper in indexes/base.py that
returns None when id_ is None instead of producing a "roboco://journals/None" URI
- Update abstract `build_source_uri` return type to `str | None` so subclasses
can legitimately signal a missing ID
- Short-circuit `ingest()` and `_prepare_docs_for_batch()` in BaseIndexPlugin
when `build_source_uri` returns None (debug log, no push to vector store)
- Fix JournalsIndexPlugin.build_source_uri: `kwargs.get("entry_id")` returns the
kwarg value even when it is None, so fall back to doc_id before calling
build_doc_source
- Fix ConversationsIndexPlugin.build_source_uri: return None when session_id is
None rather than producing "roboco://conversations/None-unknown"
- Add 9 unit tests with a piragi-free conftest that stubs sys.modules
* fix(agent_sdk): inject X-Agent-ID header on notification-poller requests
Both `_check_pending_a2a` and `_auto_ack_a2a_notifications` in
`roboco/mcp/a2a_server.py` were calling the main API without identity
headers, causing orchestrator `Missing X-Agent-ID header` warnings on
`GET /api/v1/notifications/pending-a2a` and the ack-a2a POST.
Add module-level `AGENT_ROLE` constant (mirrors the existing `AGENT_ID`
pattern) and pass `{"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE}`
on both requests.
* fix(git): use ROBOCO_PUBLIC_BASE_URL for commit-trailer Links instead of hardcoded localhost
* fix(test_runner): call uv run pytest/ruff directly; add make to orchestrator Dockerfile as backstop
FileNotFoundError was propagating as a raw 500 when a project had `make test`
configured but make was not installed in the orchestrator container.
Two fixes:
1. Catch FileNotFoundError in _run_command and re-raise as ValidationError (400)
with a clear message telling the operator to reconfigure the project command
(e.g. replace 'make test' with 'uv run pytest').
2. Add `make` to the orchestrator Dockerfile runner-stage apt-get so projects
that legitimately use make targets continue to work without reconfiguration.
* fix(api/git): resolve project by slug or UUID in git_log endpoint
Add _resolve_project_slug() helper to git routes that tries UUID
lookup first and falls back to slug, matching the pattern already
used in project routes. Apply to all four read-only git endpoints:
status, log, branches, diff.
* fix(a2a): auto-create conversation when conversation_id absent; reject empty IDs in URL builder
* fix(agent_sdk): default subagent model to parent agent's model from spawn manifest, not hardcoded haiku
Inject CLAUDE_CODE_SUBAGENT_MODEL env var into every agent container at
spawn time. Claude Code ≥2.1.x reads this variable to override the
default Task (Agent) subagent model, which otherwise hard-codes
claude-haiku-4-5-20251001. When the parent runs on a non-Anthropic
provider (e.g. Ollama Cloud / minimax-m2.7:cloud) that Anthropic model
is unreachable, so subagent dispatch fails.
The value follows the same provider-aware translation already used for
the --model CLI flag: Anthropic short names go through MODEL_MAP, and
non-Anthropic identifiers are passed verbatim. To avoid calling the
class by name inside a @staticmethod, the shared translation logic is
extracted to the module-level _resolve_agent_cli_model() helper;
_resolve_cli_model() now delegates to it.
Verified: CLAUDE_CODE_SUBAGENT_MODEL is present and honoured in the
Claude Code 2.1.123 binary (grep confirmed the env-var lookup pattern
`if(process.env.CLAUDE_CODE_SUBAGENT_MODEL) return KK(…)`).
* chore(makefile): add quality and quality-fast targets composing every PR gate
* chore(quality): add import-linter dependency and gateway boundary contract
* test(property): scaffold tracing-completeness assertion (filled in Phase 4)
* Format test file to pass ruff check
* fix(gateway): drop Protocol scaffolding from choreographer skeleton; del-statements on unused stub args; clear vulture whitelist
* linting
* feat(gateway): Phase 1 dev cutover — ChoreographerDeps + give_me_work
Add ChoreographerDeps frozen dataclass (7 deps: task, work_session, git,
a2a, journal, audit, evidence_repo), refactor Choreographer.__init__ to
accept the bundle, implement give_me_work + _briefing_for via
evidence_builder.build_context_briefing, and add property accessors for
all deps. All Phase 2-4 stubs gain del-statements and still raise
NotImplementedError. 3 tests added and passing; mypy/ruff/vulture clean.
* feat(gateway): implement i_will_work_on handling pending, claimed, and needs_revision recovery
* feat(gateway): implement i_have_committed with plan-required precondition
Replaces the NotImplementedError stub with the real implementation: looks up
the agent's active task, enforces plan presence before recording, calls
task.add_progress, and returns a structured Envelope. Adds 3 unit tests
(records progress, no active task → invalid_state, no plan → tracing_gap).
* feat(gateway): implement i_am_done with smart catch-up and skill resolution
* feat(gateway): implement i_am_blocked (struggle + escalate) and i_am_idle (with unread soft-block)
* feat(gateway): add ContentActions for commit, note, say, dm, evidence with auto-inject and validation
* feat(api/v2): add /api/v2/flow/dev/* endpoints delegating to Choreographer
Six intent-verb endpoints (give_me_work, i_will_work_on, i_have_committed,
i_am_done, i_am_blocked, i_am_idle) under /api/v2/flow/dev/, each a thin
handler that delegates to Choreographer. Includes Pydantic request schemas,
EvidenceRepo Phase 1 stub (all methods return []), get_choreographer FastAPI
dep wired with all 7 service deps, and 8 unit tests (all passing).
* feat(api/v2): add /api/v2/do/* endpoints for commit, note, say, dm, evidence
* feat(mcp): add roboco-flow MCP server for intent verbs (Phase 1: dev verbs implemented)
* feat(mcp): add roboco-do MCP server for smart-wrapped content tools
* feat(runtime): mount per-agent tool-manifest.json on developer-container spawn; gateway flag enabled for devs only
* docs(prompts): rewrite developer role prompt for gateway-only verbs (~15 lines vs 49)
* chore(mcp): confirm dev manifest excludes legacy task/journal/notify/a2a tools (Phase 1 cutover; servers retired in Phase 4)
* feat(gateway): implement claim_review with inline evidence (kills #15) and qa_evidence_inspected tracking
* feat(gateway): implement pass_review with qa_notes/learning/evidence tracing gates
* feat(gateway): implement fail_review with issue list, tracing gates, and dev A2A handoff
* feat(api/v2): add /api/v2/flow/qa/* endpoints (claim_review, pass, fail, give_me_work, i_am_idle)
* feat(mcp): add QA verbs (claim_review, pass, fail) to roboco-flow MCP server
* docs(prompts): rewrite QA role prompt for gateway verbs; explicitly warn against grep-the-commit anti-pattern
* feat(runtime): enable gateway flag for QA-role spawns (Phase 2 cutover)
* feat(gateway): implement claim_doc_task and i_documented with file-list and notes-min-chars gates
* feat(gateway): implement triage (cell PM) and triage_all (main PM) with priority order
* feat(gateway): implement unblock with pre_block_state restoration (kills #23)
* feat(gateway): implement cell_pm_complete with auto-merge to parent branch (kills #22 for cell scope)
* feat(gateway): implement main_pm_complete (open master PR + escalate to CEO)
* feat(gateway): add complete() dispatcher routing to cell_pm_complete or main_pm_complete by role
* feat(gateway): implement escalate_up routing by role.escalation_target
* feat(api/v2): add /api/v2/flow/{documenter,cell_pm,main_pm}/* endpoints
* feat(mcp): add Doc + PM verbs to roboco-flow MCP server (claim_doc_task, i_documented, triage, triage_all, unblock, complete, escalate_up)
* docs(prompts): rewrite Doc, Cell PM, and Main PM role prompts for gateway verbs
* feat(runtime): enable gateway flag for Doc, Cell PM, and Main PM roles (Phase 3 cutover)
* test(integration): full pending->awaiting_ceo_approval test through dev/QA/doc/cell-PM/main-PM gateway path
* chore(tests): rename unused args to _args in flow_server tests
Cleared RUF059 lint blocker for Phase 3 closeout. The destructured args was only consumed in URL-asserting tests; one variant only checks kwargs["json"], so its args is now _args.
* chore: untrack docs/superpowers/ + add to .gitignore
Plans + spec were inadvertently swept into commits 5d41a4b and de0c5b5 by subagent 'git add -A' calls. Removed from index and gitignored going forward; files remain on disk for ongoing reference. They still exist in history of those two commits — invoke a follow-up filter-repo if a full purge is desired.
* feat(gateway): implement Board escalate_to_ceo with role allow-list
Allows main_pm, product_owner, and head_marketing to escalate tasks to
CEO. Enforces awaiting_pm_review state and journal:decision tracing gate.
Closes Phase 4 Task 1.
* feat(gateway): implement board_triage prioritizing strategic root tasks
Adds Choreographer.board_triage and TaskService.list_strategic_for_board.
PO and Head Marketing get curated lists of strategic-nature root tasks
in awaiting_pm_review. Closes Phase 4 Task 2.
* feat(gateway): implement auditor_triage surfacing long-running blocked-task anomalies
Adds Choreographer.auditor_triage and TaskService.list_long_running_blocked.
The Auditor surfaces tasks blocked >30min as anomalies for reflect-note
observation. Closes Phase 4 Task 3.
* chore(tests): add return + arg type annotations to gateway tests
All gateway test functions now have -> None and parameter annotations. Cleared 63 mypy [no-untyped-def] errors that pre-existed since Phase 1. Mypy now clean across tests/unit/gateway/.
* feat(api/v2): add /api/v2/flow/{board,auditor}/* endpoints
Board: triage, escalate_to_ceo, i_am_idle.
Auditor: triage, i_am_idle (read-only role).
Adds EscalateToCeoRequest schema with reason min_length validation.
Closes Phase 4 Task 4.
* feat(mcp): add Board + Auditor verbs to roboco-flow MCP server
Adds escalate_to_ceo MCP tool used by Board (PO + Head Marketing) and Main PM. Updates the implemented set in _validate_role_compatibility. Auditor uses the existing triage tool with role-routing in URL.
Closes Phase 4 Task 5.
* docs(prompts): rewrite Board (PO, Head-Marketing, Auditor) prompts for gateway verbs
All 3 board identity files + roles/board.md now use the slim, gateway-aware shape (no ToolSearch directive, no state-tool table). Auditor is explicit about its read-only scope. Closes Phase 4 Task 6.
* feat(runtime): enable gateway manifest for ALL roles (Phase 4 cutover)
Adds product_owner, head_marketing, auditor to GATEWAY_ENABLED_ROLES. Every spawned agent now gets a gateway manifest mounted at /app/tool-manifest.json. The legacy briefing path is dead. Closes Phase 4 Task 8.
* test(property): implement tracing-completeness assertion across smoke-test batch
Replaces Phase 0 stub. Asserts the 6 tracing-contract requirements on every
completed task: audit_log agent_id non-null per state-transition row,
DEVELOPER:TASK_REFLECTION journal entry, QA:LEARNING journal entry,
CELL_PM/MAIN_PM:DECISION_LOG journal entry, acceptance_criteria_status
covering every criterion with a referencing_artifact_id, and
qa_evidence_inspected = true.
Uses an in-memory ephemeral Postgres test DB (`roboco_test_<pid>_<rand>`)
provisioned per pytest session, not SQLite — the production schema relies
on Postgres-only types (UUID, ARRAY) the SQLite dialect cannot compile.
Tests requesting db_session/smoke_test_batch are auto-skipped when no
Postgres is reachable on localhost:5432; ROBOCO_TEST_DB_HOST/PORT/USER
override the endpoint.
Schema is built via Base.metadata.create_all + manual ALTER for the
acceptance_criteria_status / qa_evidence_inspected columns, NOT via
`alembic upgrade head`. This sidesteps two pre-existing layer-drift items
that block any fresh migration run today:
1. Migration 001 declares the agentrole Postgres enum with lowercase
values (qa, developer, ...) but the SQLAlchemy ORM binds
Enum(AgentRole) to the StrEnum's uppercase NAMES — production DBs
mask this by being bootstrapped via create_all and stamped at 001.
2. Migration 008 runs UPDATE agents SET skills WHERE id over an
agents.skills column that no migration in this chain ever creates.
Documented in conftest.py so a future migrations cleanup can find them.
Also notes that acceptance_criteria_status/qa_evidence_inspected are in
the DB schema (per migration 006) but are NOT mapped on the ORM TaskTable
nor on the Pydantic Task model — services that read them via
`task.qa_evidence_inspected` rely on those values being set on raw rows.
The property test uses raw SQL to read the columns directly, matching the
DB-level contract.
Closes Phase 4 Task 11.
Side change: pyproject.toml — adds asyncpg.* to the existing
[[tool.mypy.overrides]] ignore_missing_imports list (asyncpg ships no
py.typed marker), matching the convention used for redis, anthropic,
piragi, etc.
Test count: 1; backend: Postgres (localhost test DB).
* style(mcp/flow_server): single-line _post call after format pass
* fix(db): map 7 gateway columns from migration 006 to TaskTable + Task model
active_claimant_id, last_heartbeat_at, pre_block_state, pre_block_assignee, pre_block_metadata, acceptance_criteria_status, qa_evidence_inspected: present in DB since migration 006 but absent from the ORM mapping. Gateway code (tracing_gate, choreographer, claimant_lock) reads these via task.<attr>; without the mapping, runtime would AttributeError. Closes PHASE4-BUG-A.
* fix(db): repair alembic chain — neutralize 008, add 009 enum reconcile, ORM uses values_callable
Three coordinated changes that close PHASE4-BUG-B:
1. roboco/db/tables.py — introduce _str_enum() helper that wraps Enum() with values_callable=lambda obj: [m.value for m in obj]. Apply to all 23 StrEnum-typed mapped columns. ORM now serializes by .value (lowercase) to match alembic 001's declared enum values; default Enum() was using .name (uppercase) which never matched.
2. alembic/versions/008_align_skills.py — replace with documented no-op. The original migration referenced agents.skills, a column that has never existed in any migration (the agents table has capabilities, not skills). The substitution intent (qa_review -> code_review) was already satisfied statically in roboco/agents_config.py.
3. alembic/versions/009_enum_reconcile.py — new migration that:
- Adds missing enum values: agentrole.system, team.fullstack, taskstatus.quarantined.
- Detects uppercase drift from a Base.metadata.create_all bootstrap and rebuilds agentrole/team/taskstatus enums with lowercase members + USING lower(col::text)::enum on every column referenced. No-op if already lowercase.
Tests stay green: 281 passed.
* feat(services): backfill 36 gateway-shaped methods for Choreographer
The gateway Choreographer was wired to call methods that the underlying
services did not expose. This adds them as thin wrappers + queries (most
alias canonical methods; a handful are gateway-specific variants).
TaskService — 26 methods: aliases (submit_verification, submit_qa,
list_blocked_for_team, list_blocked_all_teams,
list_awaiting_pm_review_for_team, list_assigned_for_agent), agent
queries (agent_for, qa_agent_for_team, documenter_for_team,
cell_pm_for_team, get_active_task_for_agent, list_paused_for_agent),
triage queries (list_awaiting_main_pm_all, all_subtasks_terminal),
state setters (set_plan, mark_evidence_inspected, mark_agent_idle),
QA/Doc claim variants (qa_claim, doc_claim, qa_pass, qa_fail),
PM completion (cell_pm_complete with merge_commit), unblock with
state restore (unblock_with_restore), and escalation
(escalate, escalate_up_to_role). Also adds GatewayAgentView
dataclass that unifies DB and config-derived agent attributes.
JournalService — 4 methods: existence checks (has_decision_for_task,
has_learning_for_task, has_reflect_for_task) + write_struggle.
GitService — 4 methods: branch-keyed entry points (create_pr,
pr_merge, pr_target, diff) plus push_branch helper. Each derives
project + workspace from the task that owns the branch / PR.
WorkSessionService — 2 methods: files_changed + has_unpushed_commits.
PR existence is the proxy for pushed (no per-commit push column).
Choreographer: switched git.push(branch_name) call to push_branch()
to dispatch to the new gateway-shaped helper.
* test(services): unit tests for 36 gateway-backfill methods
Adds happy-path + edge tests for every method added in the prior
backfill commit. Total 61 new tests across:
- tests/unit/services/test_task.py (36)
- tests/unit/services/test_journal.py (8)
- tests/unit/services/test_git.py (10)
- tests/unit/services/test_work_session.py (7)
Each test mocks at the session boundary (no DB) and stubs adjacent
service methods via a dynamic _bind helper to avoid mypy
[method-assign] noise without resorting to type:ignore comments.
* test(gateway): switch dev catch-up assertion to push_branch
The Choreographer's catch-up sequence was renamed from git.push(branch)
to git.push_branch(branch) when GitService got a gateway-shaped helper
in the prior commit. This updates the existing assertion to match.
* feat(mcp): add roboco-git-readonly server with status/log/diff/branches
Slim FastMCP server exposing the four read-only git tools every role
needs (status, log, diff, branch_list) by forwarding to /api/v1/git/*
on the orchestrator. Replaces the read-only half of the legacy
roboco-git server; write operations now go through gateway verbs in
roboco-flow / roboco-do.
The endpoint shapes mirror the panel-facing API (project_slug,
include_remote, staged/file_path) so the same backend handlers serve
both human and agent traffic.
* refactor(mcp): delete legacy task/journal/notify/a2a/message/project servers
Phase 4 cutover: agents now reach every state-changing surface through
the gateway (roboco-flow intent verbs + roboco-do content tools), with
roboco-git-readonly + roboco-optimal + roboco-docs covering reads. The
seven legacy MCP servers + their handler trees are dead code from the
agent side, so they're removed:
roboco/mcp/task_server.py (1020 LOC)
roboco/mcp/journal_server.py (512 LOC)
roboco/mcp/notify_server.py (440 LOC)
roboco/mcp/a2a_server.py (790 LOC)
roboco/mcp/message_server.py (682 LOC)
roboco/mcp/project_server.py (667 LOC)
roboco/mcp/tasks/ (handlers+utils) (~4300 LOC)
roboco/mcp/test/ (in-container runner; replaced by gateway evidence
+ manual smoke)
roboco/mcp/git/ (full server; read-only half migrates to the new
slim roboco-git-readonly module, write half is
owned by gateway verbs)
Orchestrator updates:
- _generate_mcp_config registers only roboco-flow, roboco-do,
roboco-git-readonly, roboco-optimal, and (for docs roles) roboco-docs.
No more per-role legacy fan-out.
- base_allow flips to mcp__roboco-flow__*, mcp__roboco-do__*,
mcp__roboco-optimal__*, mcp__roboco-git-readonly__*. Role-specific
allow lists are reduced to file IO scoping, since gateway verbs
enforce role policy server-side.
- TRACEABILITY_TRIGGER_TOOLS rewritten in terms of the gateway servers
(mcp__roboco-flow__* / mcp__roboco-do__*) instead of the now-deleted
per-tool list.
Test fix: tests/unit/services/test_a2a.py imported _handle_send_chat_message
from the deleted a2a_server. The four MCP-layer URL-builder tests (empty
conversation_id guard) are dropped — the equivalent boundary now lives
in /api/v2/do/* which has its own integration coverage. The two
service-layer nil-UUID guard tests are kept; they exercise A2AService
directly and remain meaningful (the panel still uses the v1 chat surface,
where a buggy caller could pass the nil UUID).
Net: ~9600 LOC removed from roboco/mcp/. quality-fast green:
338 tests pass, mypy clean, ruff clean. No /api/v1/* router changes —
those endpoints stay live for the panel UI which still uses every
lifecycle action; agents have no prompts that name them so the path is
dead code from the agent side.
* docs(claude.md): replace legacy MCP listing with gateway/verb-surface section
Phase 4 cutover: agents go through roboco-flow + roboco-do (gateway), not the deleted task/journal/notify/a2a/message/project servers. Document the verb surface per role + the Envelope response shape so future Claude Code sessions land in the correct mental model. Closes Phase 4 Task 13.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
|