Chore/all/logical gaps sweep (#286)

* release-manager: fencing-token mutex + executor/readiness hardening

Closes the release-mutex TTL race (#17, HIGH) and the remaining
release-manager gaps (#88, #89, #201, #202):

- #17: the release mutex is now acquired with a uuid4 fencing token and
  released via Lua compare-and-del; a background asyncio heartbeat
  compare-and-expires the TTL ~every 60s while the execute owns the lock,
  so a live execute no longer expires and a crashed one auto-releases
  <=3000s. A second approve after TTL expiry cannot usurp and rm -rf the
  in-flight clone — the fenced first-finally keeps its lock.
- #89: a Redis outage during acquire stays fail-closed (the execute never
  runs) but now returns a distinct redis_unavailable result + log so the
  CEO sees the cause instead of a false already_in_progress.
- #88: commit_and_push RuntimeError is wrapped into a structured
  ReleaseResult(commit_failed) instead of a 500.
- #201: first-release fallback still emits untracked version-ref files as
  gaps (no longer silenced by the first-release branch).
- #202: _await_proc awaits proc.wait() after kill() so a timeout cannot
  leak a zombie.

TDD: tests/unit/services/test_release_proposal_concurrency.py extends
_FakeRedis with eval/get/expire and pins the fencing/heartbeat/usurper
invariants + the redis_unavailable result.

* PM/code-task creation guard + main_pm coverage + issue carve-out

Closes the creation-time role x task_type gap (the user's explicit example)
and the main_pm delegate hole:

- New pure helper `pm_cannot_own_code(role, task_type, is_issue_resolution)`
  in foundation/policy/batch.py — single source of truth. Both PM roles
  (cell_pm + main_pm) coordinate; a `code` task assigned/claimed by a PM is
  a structural mismatch, EXCEPT a PM taking a code task in needs_revision to
  resolve review/QA issues directly (the carve-out).
- Creation-time guard: TaskService.create calls the helper (closes the
  create-with-cell-PM-assignee hole the team-based check misses).
- Delegate path + spec claim gate consult the same helper.
  `_validate_assignee_task_type` / `_task_type_hint_for` now key on the
  Role (CELL_PM OR MAIN_PM), not the cell-PM slug set — closes the
  delegate-to-main-pm-as-code hole.
- identity.role_for_uuid_or_none is None-tolerant (treats None as "not a
  PM" and proceeds) so a malformed/missing assignee cannot crash the guard.
- prompter.create_task_from_draft reuses the guard at draft-create.

TDD: test_batch.py (helper matrix + carve-out), test_main_pm_code_guard.py
(main_pm coverage), test_delegate_assignee_task_type.py (delegate parity),
test_lifecycle_spec.py (claim gate: rejects PM claiming code from pending,
allows from needs_revision + PM claiming planning + dev claiming code).

* task-service: completion hooks + escalation/cancel/audit hardening

Closes the task-service cluster (#21/#98, #99, #100, #101, #103, #216; #102
verified already-covered, #217 verified already-guarded):

- #21/#98: ceo_approve now closes the work session + triggers completion
  hooks before worktree removal (no-op when work_session_id is None), so a
  CEO-approved task lands the same close-path as PM-completed.
- #99: apply_escalation routes through the transition validator with an
  enumerated escalation exemption (_ESCALATABLE_TO_BLOCKED) instead of an
  arbitrary source->BLOCKED write; BACKLOG is refused.
- #100: branchless ceo_reject awaiting_ceo_approval->pending gets a real
  spec edge (ceo_reject_to_pool ActionSpec + _STATUS_TRANSITIONS entry) so
  future admin-override tightening can't wedge the path.
- #101: revision_count bump is documented as the single chokepoint, with
  the pre-block RESTORE path undoing it when restoring a snapshotted
  needs_revision (same cycle resuming, not a new rejection).
- #103: cancel cascade surfaces non-terminal orphans instead of swallowing
  the role violation.
- #216: _remove_task_worktree_on_terminal escalates recurring FS/permission
  failure (audit/notify after N) instead of silent-failing forever.
- #102: pinned in test_verb_runner_midverb_invalid_state.py (committed with
  the choreographer cluster) — verb-runner savepoints already surface a
  concurrent mid-verb state change as INVALID_STATE.
- #217: submit_for_qa claimed_by guard verified intact.

TDD: test_task.py, test_worktree_cleanup_on_complete.py,
test_escalation_board_guard.py (#99), test_task_service_* integration,
test_lifecycle_spec.py.

* choreographer: gate-claim guards + pr-gate hardening + fail-open logging

Closes the choreographer cluster (#5/#222, #29, #30, #82, #188, #189,
#192; #157/#187 verified already-fixed/pinned; #102 pin lives here):

- #5/#222: the unchanged-PR guard's fail-open head_sha lookup now logs
  (warning) on a slug-resolver/git-helper error so a regression cannot
  silently turn the pr_fail re-submit loop-stopper into a no-op. Stays
  fail-open (never wedges the PM).
- #29: pinned (REFUTED-with-pin) — _lane_claim_guard already returns the
  error envelope without releasing the claim on a transient lookup error.
- #30: pinned (REFUTED-with-negative-pin) — a non-batch branchless main_pm
  root cannot bypass the complete spec gate (is_batch_umbrella requires
  batch_id set).
- #82: _post_gate_review_to_pr wraps the slug-resolution call in try/except
  (mirrors _capture_pr_head_sha) so a malformed cell_map AttributeError no
  longer 500s the reviewer after a committed gate transition.
- #188: _is_hand_formatted_verdict anchors the header regex to line-start,
  so a quoted (> ## Summary) or inline (mid-prose) header mention no longer
  false-refuses a hand-formatted verdict.
- #189: pr_fail re-captures the PR head SHA after the transition commits and
  re-stamps the verdict note only when it advanced (closes the stale-SHA
  false-allow loop-hole); no-advance stays a single note write.
- #192: claim_gate_review skips the dev claim guards (already_active/paused/
  lane) via a new skip_dev_guards param — a pr_reviewer inspecting an
  assembled PR does not start work, so the single-active-task / code-lane
  invariants do not apply; the dependency guard is kept, and QA's
  claim_review parity is preserved.
- #157/#187: verified in tree — pr_review-only handoff is intentionally
  prior-work-worth-resuming; self_review_block wiring (reviewer != dev)
  holds on assembled tasks with 4 existing pin tests.

TDD: test_choreographer_*, test_pr_gate_posts_review (#82),
test_pr_review_hand_format_guard (#188), test_submit_root_unchanged_pr_guard
(#189), test_claim_gate_review_guards (#192), test_verb_runner_midverb
_invalid_state (#102 pin).

* playbook curate: guard the gating commit against a poisoned session (#55)

The explicit `session.commit()` that gates the RAG index (commit-before-index
so an uncommitted playbook cannot land in the corpus) raised PendingRollbackError
when a prior mid-verb failure had rolled the caller's session back — 500ing the
whole curation verb instead of returning a clean envelope, and (worse) risking a
fall-through to index an uncommitted playbook. Wrap the commit: on
PendingRollbackError, log + return invalid_state with a re-fetch/retry remediate
and skip the index. The happy path still commits exactly once then indexes.

TDD: test_playbook_verbs.py — poisoned-session returns a clean invalid_state and
does NOT index; clean-session still commits once + indexes (pins no fail-closed
inversion / no double-commit).

* [chore] gateway: atomic activate merge — preserve probe_failures across re-park (#156)

activate() was a blind SET that reset probe_failures to 0, so a probe-failure
increment that just landed (or was in flight) could be wiped by a concurrent
re-park — resetting the give-up / CEO-notify count mid-episode. Route activate
through a server-side Lua merge (roboco:activate_rate_limit) that refreshes the
episode metadata (kind / activated_at / retry_after / affected_agents) while
carrying over the previous probe_failures count. Indivisible w.r.t. the
increment/reset scripts (Redis single-threads an EVAL).

#56 (notify to prompter/secretary refused) verified SAFE — the pin tests
(test_notify_rejects_prompter_recipient / _secretary_recipient /
_allows_ceo_recipient) already cover the only human notify target invariant;
no legitimate send is dropped, no code change.

* [chore] foundation/policy: spec gates + QA retry-key pin (Cluster F)

#50 sync_branch composes=() so the spec gate accepted a terminal/paused/
blocked task and the handler rebased a dead/parked branch — add a
PRECONDITION_SYNC_BRANCH_STATE (claimed/in_progress/verifying/needs_revision
only), rejection_kind=invalid_state. TDD: 28 spec tests.

#148 submit_root's prose asserts 'a Main-PM root is planning-typed, never
code' but only the creation path (main_pm_cannot_own_code) backed it — add
PRECONDITION_ROOT_NOT_CODE on the submit_root IntentSpec (defense in depth),
scoped to submit_root only so the shared submit_for_review action keeps
cell_pm+code submit_up parity. Graceful on Mock/None task_type so the
choreographer Mock-task tests don't crash. TDD: 2 spec tests.

#150 VERB_RETRY_LIMITS is keyed by the MCP-exposed names (pass/fail), not
the IntentSpec-internal pass_review/fail_review — already correct; add a
pin test so a one-sided rename can't silently drop the QA-handoff cap.

#142 main_pm_cannot_own_code/pm_cannot_own_code already normalize casing
(.lower()) — no-op, pin test test_main_pm_cannot_own_code_is_case_insensitive
already in tree.

* [chore] worksession-git: 405 merge-method fallback + non-destructive close (Cluster W)

#108 _merge_with_retry hardcoded 'squash' and raised MergeConflictError on a
405 with no method fallback — wedging the PM on an open, mergeable PR whose
repo merely had the squash button off. Add a 405 fallback to a permitted
method (via _first_allowed_merge_method, exclude='squash'), mirroring the CEO
merge_pull_request path. A 405 with no permitted fallback (or a second 405)
still falls through to the already-merged disambiguation / MergeConflictError.
TDD: 2 new tests (fallback-success, no-permitted-method-raises).

#109 close_pull_request defaulted delete_branch=True, so the choreographer
supersede path deleted a superseded PR's branch while the orchestrator
supersede path explicitly preserved it — the two disagreed, and the
destructive default ran on the 'close the dead PR' path where the branch may
still be referenced / useful for audit. Flip the default to False (opt-in
deletion) and make the choreographer caller explicit (parity with the
orchestrator). TDD: 1 new test (default preserves branch); existing
deletion-when-requested test now passes delete_branch=True explicitly.

Dispositions verified against current code (no silent drops):
- #27 REFUTED/FIXED-UNDEPLOYED: work_session.merge_pr resolves by session_id
  (no global pr_number lookup); the real cross-repo collision fix
  (project_id scoping on pr_merge/close_pull_request/rebase_pr_for_task/
  pr_target) is already in tree + tested (test_pr_merge_scopes_task_lookup_
  by_project_id, test_close_pull_request_scopes_task_lookup_by_project_id,
  test_git_pr_target_scoping). Verify-only.
- #106 REFUTED: a guard exists (rev-list --count {base_ref}..{branch} == 0)
  before reset --hard + base_ref falls back to default_branch; tests lock
  the safety (test_create_branch_never_repoints_branch_with_real_work,
  test_create_branch_does_not_reset_or_checkout_shared_clone).
- #218 BY-DESIGN: the merge_pr idempotent guard intentionally preserves the
  audit trail (docstring + test_merge_pr_idempotent_on_already_completed_
  preserves_audit_trail); a COMPLETED session always carries attribution
  (COMPLETED only via merge_pr), so the NULL-COMPLETED case is unreachable.
- #104 BY-DESIGN: agents never merge to the repo default branch in RoboCo's
  model (root→master is CEO-only); the guard is a correct CEO-only rail,
  locked by test_pr_merge_into_default_branch_is_ceo_only.

* [chore] llm: surface disabled-provider downgrade + scrub probe log (#20/#3/#211)

#20/#3 resolve_for_agent silently fell through to the legacy Anthropic path
when a configured provider was disabled — indistinguishable from 'no
assignment', so the operator got no signal that spawns bypassed the
provider. Surface the bypass with a warning (graceful degradation stays the
default — a stalled spawn is worse than a routing miss) and add an opt-in
ROBOCO_ROUTING_STRICT (default-off) that fail-closes instead. Wired into the
panel Feature Flags card. TDD: 3 unit tests (warn-on-disabled, strict-raises,
no-assignment-stays-silent).

#211 probe_ollama_tags logged str(exc) raw on the generic-exception branch —
structured log could carry connection internals / stack traces. Log the
exception class name only. TDD: existing generic-branch test strengthened to
assert the log kwargs don't leak the raw text.

* [chore] support/stream/optimal/playbook/comms hardening (Cluster S)

Logical-gaps sweep, Cluster S (TDD, red→green per item):

#64 notification_delivery.acknowledge published the NOTIFICATION_ACKED bus
event directly (bypassing the outbox) — a rollback left a phantom ACK. Route
it through defer_bus_publish (after_commit), mirroring deliver.

#76 playbook.archive()/reject() stamped the archiver into approved_by/
approved_at, overwriting approval provenance (and fabricating approval for a
rejected draft). Add archived_by/archived_at (migration 053 + table + model)
and write those on archive/reject, leaving approval attribution intact.

#181 vector_store.replace_chunks wiped existing index rows even when every
chunk lacked an embedding (embedder failure). Skip the wipe when chunks is
non-empty but records is empty — preserve good rows for nothing.

#182/#183 optimal.record_learning recomputed a learn-{md5(full_content)}
tracking source that never matched the URI the plugin embedded chunks under
(roboco://learnings/{doc_id}, doc_id=lrn-{hash100}). Use the plugin's
returned doc_id so de-index/lookup-by-source finds the chunk rows.

#96/#97 transcription periodic flush only peeked ready buffers (unbounded
map growth) and ran sync callbacks on the event loop (a slow callback
blocked the flush task). Flush (remove) each ready buffer after notifying,
and offload each callback to a thread.

#212 _TEAM_SCOPED_ROLES was duplicated across communications/agents_config/
seeds. Single-source it in foundation.policy.communications; consumers
reference that object (identity-tested).

#19 stream_bus._dispatch_event re-ran already-succeeded handlers on a
recover_pending replay (duplicate side effects). Add a per-(event.id,
handler) SET-NX idempotency guard: skip on a hit, clear the key on handler
failure so a replay re-runs it, fail-open when redis is unavailable.

Dispositions (no code change): #77 approve() index-write pair asserted
BY-DESIGN; #62/#63 notification DB-dedup verified pinned; #184/#185 REFUTED;
#214 REFUTED; #215 BY-DESIGN.

* [chore] db/migrations: graph-integrity guard + conftest unreachable-DB warning (Cluster D)

Logical-gaps sweep, Cluster D (TDD + real alembic upgrade head verification):

#16/#37 add tests/unit/test_migration_graph_integrity.py — a static guard that
the alembic migration graph has exactly one head, every down_revision resolves,
every revision is reachable from a root, and no revision id is duplicated. The
suite builds its DB via Base.metadata.create_all (not alembic upgrade head), so
a forked head / dangling down_revision / duplicate id would otherwise ship
silently and break a real deploy mid-stream.

Caught a real bug in the process: migration 053's revision id
"053_playbook_archived_attribution" (33 chars) exceeded alembic's
alembic_version.version_num VARCHAR(32) — a fresh `alembic upgrade head` raised
"value too long for type character varying(32)" at the 053 stamp. Renamed to
"053_playbook_archived_attr" (26 chars). Verified end-to-end on a scratch PG:
upgrade head stamps 053, downgrade -1 returns to 052. (The pre-existing
test_every_migration_revision_id_fits_the_alembic_version_column guard is now
green too; it had been red on the 33-char id.)

#90 conftest silently pytest.skip'd every DB test when Postgres was unreachable
— a non-Docker box reported a green run of all-skips. Extract the warning into
_warn_if_pg_unavailable and fire it at import so the operator sees the DB is
down (the per-test skip path is unchanged). Test: warns when unavailable, silent
when reachable (verified under -W error::UserWarning).

Dispositions (verified against real code + a fresh alembic upgrade head, no code
change): #6 REFUTED — sa.Enum(create_type=False) at 001:119/304 does NOT break a
fresh upgrade head (001→052 applied cleanly on a scratch DB); #8 REFUTED — the
upgrade passed 030/031 (RAG chunk tables) without pgvector installed; pgvector is
a runtime concern handled by roboco/db/base.py, not a migration prerequisite;
#40 REFUTED — the `|| echo` mask was already removed and partial-schema drift
reports exit 1 (only by-design unreachable/unmigrated skips remain); #204 REFUTED
— the property walk seed IS pinned (random.Random(20260504), line 97); #205/#206
REFUTED — the smoke-trace fixture IS wired via
test_lifecycle_smoke_replay.py (8 passed); no shell smoke scripts exist in the
tree to wire; #137 BY-DESIGN — pyproject version 0.14.0 is an operational note,
no code gate.

* [chore] panel: admin-override force flag + kanban subtask_count + ws cleanup + ui-store dedupe (Cluster P)

#13: kanban admin-override into a hatch state (completed / awaiting_qa /
awaiting_pm_review) now requires an explicit force=true from the panel and
emits a dedicated task.admin_override audit row server-side; non-hatch
overrides need no force. Backend gate in tasks route + admin_set_status;
panel kanban-board sends force for hatch targets; TaskUpdate carries force.

#198: kanban service threads the real subtask_count (one grouped query) into
dev + priority-swimlane + main-pm-flat boards instead of a hardcoded 0.

#79: useWebSocket cleanup clears messages/lastMessage/state on unmount or
endpoint change so a dep-change (navigating to another stream) can't leak the
prior subscription's stale snapshot as live.

#186: disambiguate the duplicate ui-store modules -- the session/scroll store
in lib/stores renamed to useScrollRestorationStore / scroll-restoration-store
(barrel + 2 consumers updated); the sidebar/theme useUIStore in @/store is now
the sole useUIStore.

#12: verified already in-tree (release-proposal-card surfaces non-404 errors
with retry; getProposal maps only 404->null). #80 by-design (handleTransportError
already resets isSending on a no-payload SSE drop). #81 docs (streamUrl docstring
records that live-intake SSE auth is session-id-based bearer-style).

Backend: ruff+mypy clean, 278 tests green. Panel: lint+typecheck clean, 159 tests.

* orchestrator: park/reaper/readopt/a2a hardening + self-heal/ci-watch dedupe (Cluster O)

Closes the orchestrator-side logical gaps from the sweep:

- #75 a2a human-only drop surfaced: _dispatch_a2a_work logs the skip
  ("a2a request targets a human-only role; left as a notification for the
  human (not spawned)") instead of silently dropping the target — the
  CEO/secretary/prompter still see the notification; only the spawn is
  suppressed. (orchestrator.py)
- #72 readopt liveness: _readopt_running_agents requires a non-stale live
  claim (via _agent_holds_live_claim) and skips a zombie container so a
  reaped-but-restart-readopted agent isn't double-counted as active.
- #74 shutdown drain: stop() calls _flush_respawn_tracker so the durable
  respawn counter write-throughs aren't lost on a clean stop.
- #71 resolve_wait active-guard + deferred liveness: a rate_limit_lifted
  WaitingRecord is only confirmed-live after a _confirm_resume_liveness
  probe (deferred deletion _resume_confirm_delay=30.0), and an
  already-active agent short-circuits the repark. Scoped to
  rate_limit_lifted records (the only ones at risk of a false lift).
- #73 stuck-Claude kill: _maybe_kill_stuck_claude + _claude_stuck_kill_ttl
  (config.claude_stuck_kill_seconds) — a live container whose heartbeat is
  stale past the grace AND whose gateway probe is broken is killed+evicted,
  not protected forever by the reaper's live-skip.
- #230 verified FIXED-UNDEPLOYED: _gateway_broken_past_grace already
  requires N consecutive false-broken probes (not one flaky streak); no
  change, test added to pin the N-consecutive invariant.
- #43 self-heal per-observation dedupe: a fingerprint collapses repeat
  CEO notifications for the same CI regression.
- #44 ci_watch dedupe by (git_url, workflow): a monorepo's multiple
  workflows each get their own fix task (was collapsed by git_url alone).
- #49 identity.role_for_slug_or_none None-hardening: a stale/malformed
  slug resolves to None and the human-only skip falls through to the safe
  "not spawnable" path instead of crashing.
- #193 strategy engine: notify the CEO on a persistent assess failure
  instead of failing silently in the background loop.

TDD: test_no_spawn_human_roles (a2a skip surfaced), test_orchestrator_
shutdown_drain (#74), test_provider_overload_break (#71), test_readopt_
running_agents (#72), test_resolve_wait_repark (#71), test_stale_claim_
reaper (#73/#230), test_strategy_engine_loop (#193, new),
test_self_heal_engine (#43), test_ci_watch_engine (#44), test_identity
(#49). All red->green.

* chore: make-quality green — xenon complexity refactors + mypy test fixes + lifecycle regen

No behavior changes. Brings the tree to a fully green `make quality` (the
base branch never passed the xenon B-rank gate on several blocks; the
lifecycle artifacts had drifted from the committed ceo_reject_to_pool edge).

Xenon B-rank refactors (extract a helper; preserve semantics exactly):
- api/routes/tasks.py: _apply_forced_status_override + _StatusOverride
  dataclass bundle (update_task override block).
- services/task.py: _enforce_no_pm_code_on_create (create guards) +
  _escalation_diverts_to_pool (collapses the two board/advisory +
  main_pm+code divert branches into one predicate).
- services/prompter.py: _coerce_pm_code_to_planning (create_task_from_draft).
- services/notification.py: _duplicate_unacked_exists (_create_notification
  purpose-based dedup query + ACK_REQUIRED_BY_TYPE gate).
- services/sequencing.py: _same_assignee_lane_edges (the undeclared-surface
  same-assignee lane fallback at the tail of dev_task_collision_edges).
- gateway/choreographer/_impl.py: _pm_task_type_error static helper
  (_validate_assignee_task_type compound PM guard).
- gateway/choreographer/pr_gate.py: _gate_review_event_verdict +
  _gate_review_body static helpers (_post_gate_review_to_pr).

mypy test fixes (no type:ignore — banned; use typing.cast with quoted
strings per TC006):
- test_task_update_completeness: TaskUpdate(acceptance_criteria=None).
- test_bus: cast("Redis", _FakeRedis()); Redis import under TYPE_CHECKING.
- test_pr_merge_concurrency: capture AsyncMocks into locals before asserting.
- test_notification_delivery_phantom: cast("UUID", to_agents[0]).

Lifecycle artifact regen (owed from Cluster T #100 — the
awaiting_ceo_approval -> pending `ceo_reject_to_pool` edge was added to the
spec in 3d633084 without regenerating the derived artifacts the
foundation-check gate diffs against): docs/rag/lifecycle/intent-verbs.md,
docs/rag/lifecycle/status-transitions.md, panel/lib/lifecycle.json.

services/kanban.py: ruff format only (collapses the _load_subtask_counts
signature that drifted unformatted from Cluster P).

* [chore] logical-gaps sweep — Cluster I (intake/product/pitch)

#57/#58 prompter: preserve a top-level product_id with a 1-cell map
(prompter.py create_task_from_draft — top-level target wins over a
redundant 1-cell map instead of dropping product_id); reject — not
silently skip — a malformed project_id in the_work cell entries
(prompter.py _draft_cell_map raises ValidationError).

#59/#159 prompter: create_task_from_draft now operates on a copy
(_copy_draft) so _validate_and_coerce_draft / _clean_list never mutate
the caller's draft dict.

#160 prompter: _resolve_owning_team consults product/board routing
before forcing MAIN_PM on a multi-cell map (product root stays Board,
product+assignee-is-board stays Board).

#83/#84 github_provisioning: create_repo is idempotent by GitHub name
— a 422 "name already exists" (orphaned repo from a rolled-back prior
approval) is fetched and reused instead of erroring; pitch re-approval
now reuses the orphaned repo end-to-end.

#196 kanban: flat main-PM board has a "coordination" column for
non-cell teams (MAIN_PM/Board) instead of dropping their cards.

#197 project update: an explicit null in the PATCH body now clears the
stored field, distinct from an absent field (leave unchanged).
ProjectService.update drops exclude_none so explicit-None applies; the
PATCH route uses ProjectUpdate.model_validate(data.model_dump(
exclude_unset=True)) to preserve the request's unset-tracking (the old
field-by-field construction marked every field set and defeated the
distinction — nulling NOT-NULL git_url).

TDD: prompter 47, github_provisioning+pitch 16, kanban+project 67,
project routes 37 — all green; ruff + mypy clean.

* [chore] logical-gaps sweep — Cluster M (mcp-servers)

#60 flow_server/do_server: the circuit-breaker substitution no longer
erases the fixable rejection — the original envelope (kind/message/
remediate) is nested as inner on a copy of the SDK's circuit_open
envelope (the SDK dict is not mutated in place). The agent still sees
WHY the verb failed, not just that the breaker tripped.

#61 flow_server/do_server: a 404 carrying a *descriptive* detail
(not FastAPI's bare default {"detail":"Not Found"}) is now a
real resource not_found, surfaced as not_found so the agent
re-fetches state — instead of a misleading "server-side wiring gap"
invalid_state. The bare default and unparseable 404s still synthesize
the wiring-gap envelope; a 404 with a real Envelope (error field)
is still surfaced as-is.

#161 flow_server/do_server: dict error.code classification now uses
an exact-code map (authoritative for the codes the handlers emit) with
a substring fallback for unknown codes. Fixes the real regression:
AUTHENTICATION_REQUIRED carries no AUTHORIZED/DENIED/PERMISSION
substring, so the old substring-only rule dropped it to invalid_state
instead of not_authorized — an auth storm attributed as a state storm.
The fallback also adds AUTH so future AUTH-prefixed codes classify.

#162 flow_server/do_server: _register_tools gains a
ROBOCO_ALLOW_FULL_TOOLSET env override (default-off) so a missing
manifest falls back to the full tool set instead of raising — a
dev/test escape hatch. Production fail-loud behaviour is unchanged.

#163 intake_server: propose_batch accepts name as well as
title (intake drafts in the wild have used both), normalizing a
name-only draft onto a copy as title (caller's dict never mutated),
and reports the dropped count + reason in the return instead of
silently vanishing malformed drafts. The empty-batch hint now names
name as an alternative.

TDD: 123 mcp_servers tests green (14 new + 2 updated); ruff + mypy clean.

* [chore] Cluster N — conventions/docs logical-gaps sweep

#33: _create_new_doc/_update_existing_doc now resolve via
_resolve_contained_path (the RAG-returned update path was not containment-
checked — an escaping source could write/overwrite outside the docs dir).
#34: _commit_doc_to_repo returns committed/skipped/failed instead of
swallowing all exceptions; surfaced on DocRef.commit_status, the write
response, and the docs MCP guidance so a failed repo commit is fail-loud.
#35: write_doc only updates the similar doc when its filename matches — a
different filename creates a new file instead of collapsing onto the
similar doc's path (the dedup-overwrite defect codified by the old tests).
#129: a custom rule scoped to a language the validator never reports (a
typo) is surfaced as a warn finding on .roboco/conventions.yml via the
runner's once-per-run validation; #32 (tsx->typescript dialect) stays
BY-DESIGN.
#130: _cache_put only swallows a UNIQUE violation (23505) as a concurrent
duplicate; a non-unique IntegrityError (FK/NOT NULL/check) is log-errored
and re-raised instead of being silently misattributed.
#132: health re-reads the live file status (a cached degraded row hid an
in-place repair at a stale head key); get_map skips cached degraded rows
and stops caching degraded so a repaired file re-derives. #134 BY-DESIGN.
#133: _DB_METHODS gains stream/stream_scalars (SQLAlchemy 2.0 streaming
constructs are data access too — a route calling them is not thin).
#199: regenerate_verb_tables._annot_str strips Annotated[...] metadata
(BeforeValidator) before rendering; regenerated verbs.md + per-role
prompts so the BeforeValidator(func=...) repr (with a memory address) no
longer leaks into agent-facing prompt text.

TDD: 206 conventions/docs tests green (incl. 5 new files / appended
cases); ruff + mypy clean.

* [chore] Cluster 16 — cross-cutting hygiene logical-gaps sweep

Disposition + fix the 10 cross-cutting-hygiene gaps, TDD. make quality green
(ruff, mypy 944 files, pytest, xenon, vulture, foundation-check, enum-parity).

FIX:
- #24 /ws/system now gated by _require_panel_token (matches every sibling
  /ws/* stream); rejects a missing token in strict mode and a forged token
  even in dev. (roboco/api/websocket.py)
- #25 two drifted _require_ceo implementations (orchestrator router vs release
  handler) unified on a single require_ceo_role helper in deps — same 403,
  same role set, accepts Role/AgentRole/"ceo". (roboco/api/deps.py,
  routes/orchestrator.py, routes/release.py)
- #11 a spawn session for a delivery role (developer/qa/documenter) with no
  task_id now logs an unattributed-usage warning via is_unattributed_delivery_spawn.
  (roboco/runtime/orchestrator.py)
- #65 pricing returns a structured CostResult(cost_usd, unpriced, is_anthropic)
  so an unpriced Anthropic model (real spend we'd undercount) is flagged
  instead of silently $0; calculate_cost stays a thin float wrapper.
  (roboco/billing/pricing.py, billing/__init__.py)
- #67 blocker-metrics "blocked since" reads the task.blocked audit transition
  (indexed on target_id/event_type/timestamp), not updated_at — which
  over-counted when a blocked task was touched for a non-blocking reason.
  Falls back to updated_at/created_at only with no audit row.
  (roboco/services/metrics.py)
- #94 grok refresh_if_stale uses double-checked locking (_refresh_lock +
  _recheck_or_refresh) so two concurrent callers don't both POST the
  single-use refresh grant and burn the credential. (grok_auth.py)

DOCS (fix the doc, behavior already correct/pinned by tests):
- #66 get_summary docstring corrected — it sums raw agent_spawn_sessions rows
  (sub-day precise); daily_usage_rollups/get_today_summary can diverge for
  "today" until the sweeper catches up. (roboco/services/usage.py)
- #68 DashboardStorage is a documented in-memory stub; added a test pinning
  that auditor flags are lost on storage reset (persisting = a migration +
  service refactor, out of scope as a half-implementation).
  (tests/integration/test_dashboard_service.py)

BY-DESIGN (no code change, with file:line evidence):
- #28 dashboard reads are open to the authenticated operator (dashboard.py:36
  documents this); mutating auditor routes already gate via
  _require_auditor_or_ceo. Role-gating reads would break the panel (no
  X-Agent-ID on dashboard reads) and CEO-token-gating the router would block
  the Auditor (auditor token != CEO token). nginx is the prod boundary.

REFUTED (narrowing would reintroduce a documented hang):
- #93 the ~/.grok directory mount (vs a single auth.json file) is load-bearing
  — a single-file bind mount pins the inode so the atomic tmp.replace refresh
  doesn't propagate to running containers (they hang at grok's login prompt).
  Already documented in grok.py:161 and locked by
  test_intake_grok_mounts_subscription_auth_when_present.

Incidental gate-greening (mypy errors a stale .mypy_cache had hidden in
earlier-cluster test files; xenon refactors for the new B-threshold):
- tests/unit/test_regenerate_verb_tables.py: type the dynamic-module loader.
- tests/unit/services/test_prompter.py: annotate the draft dict as dict[str,Any].
- tests/unit/services/test_conventions_cache_put.py: _FakeOrig is a real Exception
  (IntegrityError's orig arg requires BaseException).
- metrics._blocked_since_map extracted from get_blocker_metrics (complexity).
- intake_server._normalize_batch_drafts extracted from propose_batch (complexity).

* Docs update

* [bug] spawn: self-heal vanished clone + branch ref before worktree ensure (be-dev-1 fatal loop)

A vanished clone_root (disk loss / /data/workspaces wipe / manual cleanup)
fatal-looped the resume path: _ensure_worktree_before_spawn ran
`git -C <missing>` and released the claim, but the reaper-style release
preserves assigned_to + branch_name so the next dispatch is a RESUME
(create_branch never re-runs to re-clone) and the same missing clone failed
every ~30s.

- workspace.py: ensure_worktree_self_heal re-attaches a present worktree +
  symlinks the shared .venv; on a missing local branch ref it fetches from
  origin (create_branch pushes at claim time, so pushed work survives) and
  re-creates the ref, falling back to -b origin/HEAD only when the branch
  was never pushed. _fetch_branch_ref is the token-aware fetch helper.
- orchestrator.py: _ensure_worktree_before_spawn health-checks the clone
  and re-clones via ensure_workspace BEFORE the worktree self-heal. Fatal
  git-state (WorkspaceError) still releases the claim + aborts; transient
  failures abort without releasing (a fresh claim wouldn't help and
  re-cloning is destructive).

TDD: 21 new + 61 related worktree/git/cancel/cleanup tests green; ruff +
mypy clean.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-30 08:08:35 +02:00
committed by GitHub
co-authored by Renn F
parent cf3c2d38b3
commit 536bbb64f3
169 changed files with 7973 additions and 687 deletions
+5 -1
View File
@@ -25,7 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Loop-prone notifications now have a bounded re-fire guard.** `TASK_ASSIGNMENT` / `REVIEW_REQUEST` / `DOCUMENTATION_REQUEST` / `BROADCAST` can be re-fired by a coordinator PM every tick while a task sits in a state, flooding inboxes. The existing DB purpose-dedup never fires for these four (`ACK_REQUIRED_BY_TYPE` marks them `requires_ack=False`, so the dedup is gated off), and the delivery path (`_persist_and_deliver`) had no dedup at all — so a wedged task re-sent the same signal every cycle, inflating each recipient's unacked set and driving respawn churn. A 60s Redis `SET NX` window per `(type, sender, recipient, task)` now coalesces the re-fire on both creation chokepoints (`NotificationService._create_notification` and `NotificationDeliveryService._persist_and_deliver`): the first fire acquires (marks) keys for fresh recipients, subsequent fires within the window are suppressed when no recipient was fresh, and the storm converges. Fail-open: Redis unavailable → never suppress (a notification is never dropped over dedup infra). One-shot types (`KNOWLEDGE_SHARE` / `MENTION` / `A2A_REQUEST`) bypass entirely (distinct content per send, no dedup key).
- **A whole-codebase logic-gap audit — roughly 140 concurrency, scoping, signal, and lifecycle gaps fixed.** The dominant body of this release. The categories: **cross-repo PR scoping**`pr_number` and `branch_name` are per-repo but were stored and looked up unscoped, so two tasks on different repos sharing a PR number could merge the wrong repo's PR or skip the org's own in-flight integration PR; every PR-merge and branch-ownership lookup is now `project_id`-scoped, and `close_pull_request` / `pr_target` make `project_id` mandatory. **Advisory locks closing TOCTOU races** — per-agent on claim, per-parent on `delegate`, per-task on `open_pr` (preventing a milestone double-emit), plus an atomic server-side Redis probe-failure counter and a single-transaction `replace_chunks` (delete+insert) closing a reindex race. **Audit-row transactionality** — status-transition audit rows and the rework counter are written in-session in the caller's transaction (the old fire-and-forget path is gone), so the audit trail can't diverge from the state change. **Signal gaps**`pr_fail` now pushes the reviewer's issues to the owning cell PM (the re-submit loop where a PM respawned into `needs_revision` blind and re-submitted the same PR is closed), and `fail_qa` routes a `needs_revision` dev task back to the dev, never the pool. **Asyncio cleanup**`OptimalService.close()` cancels its startup indexing task before the periodic task and the plugin clear, so it can't write against closed plugins. **Conventions standard** — the validator now times out and reaps on hang, and the gate fails closed on resolution errors (a broken standard can no longer silently disable the gate). **WebSocket** — fan-out is non-blocking with finally-disconnect, idle-timeout, and dead-socket reaping on send error. **Orchestrator runtime** — it drains its fire-and-forget background set on shutdown and stops in lifespan shutdown before closing the DB; the probe-resume loop actually revives parked agents; the grok auth token is refreshed before expiry and parked (not crash-retried) when missing. **Release executor** — every subprocess (git/make/gh/clone) is deadline-bounded and it fails closed on a git add/commit before push. Dozens more across org-memory (private-leak closures, playbook index/unindex as a post-commit step so the RAG corpus never leads the status transaction), the reaper, the provider-park/overload break, and the live-chat bridges. The full categorized tracker lives in `docs/internal` (gitignored).
- **A whole-codebase logic-gap sweep — 230 deduped regression risks plus the PM/code-task creation guard, every one dispositioned against the live tree.** The dominant body of this release. Each item was read against the real code first (four of the prior batch's Highs had been false alarms, so the inventory was never trusted blindly), then TDD-fixed; the dispositions ran 86 FIX, 78 BY-DESIGN (intentional/documented tradeoffs, with the silent-swallow-only cases reclassified to FIX with logging added), 18 REFUTED (the cited code already guards it), and 8 DOCS. The categories: **cross-repo PR scoping**`pr_number` and `branch_name` are per-repo but were stored and looked up unscoped, so two tasks on different repos sharing a PR number could merge the wrong repo's PR or skip the org's own in-flight integration PR; every PR-merge and branch-ownership lookup is now `project_id`-scoped, and `close_pull_request` / `pr_target` make `project_id` mandatory. **Advisory locks closing TOCTOU races** — per-agent on claim, per-parent on `delegate`, per-task on `open_pr` (preventing a milestone double-emit), plus an atomic server-side Redis probe-failure counter and a single-transaction `replace_chunks` (delete+insert) closing a reindex race. **Audit-row transactionality** — status-transition audit rows and the rework counter are written in-session in the caller's transaction (the old fire-and-forget path is gone), so the audit trail can't diverge from the state change. **Signal gaps**`pr_fail` now pushes the reviewer's issues to the owning cell PM (the re-submit loop where a PM respawned into `needs_revision` blind and re-submitted the same PR is closed), and `fail_qa` routes a `needs_revision` dev task back to the dev, never the pool. **Asyncio cleanup**`OptimalService.close()` cancels its startup indexing task before the periodic task and the plugin clear, so it can't write against closed plugins. **Conventions standard** — the validator now times out and reaps on hang, and the gate fails closed on resolution errors (a broken standard can no longer silently disable the gate). **WebSocket** — fan-out is non-blocking with finally-disconnect, idle-timeout, and dead-socket reaping on send error. **Orchestrator runtime** — it drains its fire-and-forget background set on shutdown and stops in lifespan shutdown before closing the DB; the probe-resume loop actually revives parked agents; the grok auth token is refreshed before expiry and parked (not crash-retried) when missing. **Release executor** — every subprocess (git/make/gh/clone) is deadline-bounded and it fails closed on a git add/commit before push. Dozens more across org-memory (private-leak closures, playbook index/unindex as a post-commit step so the RAG corpus never leads the status transaction), the reaper, the provider-park/overload break, and the live-chat bridges. The single HIGH was the release-mutex TTL race (its own bullet below). The full readjusted mapping — every gap → disposition → `file:line` → how it works now — lives in `docs/internal/how-it-works-now-2026-06-30.md` (gitignored).
- **The release mutex is now fenced + heartbeated (the sweep's single HIGH).** `ReleaseProposalService.approve` guarded the fail-closed `ReleaseExecutor` with a Redis `SET NX EX` lock, but the lock held a static value (no fencing token), had no heartbeat, and its TTL (~50 min) was shorter than the worst-case clone+gate+CI+publish run (~90 min). On TTL expiry a second CEO approve re-acquired and `_prepare_release_clone` `rm -rf`'d the in-flight shared clone. The lock now carries a uuid4 fencing token; release is a Lua compare-and-del that only fires when `GET == token` (a late first-finally cannot delete a usurper's lock); a background heartbeat refreshes the TTL every 60 s while execute owns it. A Redis outage is distinguished from a held lock and both stay fail-closed (`redis_unavailable` vs `already_in_progress`).
- **The 2026-06-27 live-run meltdown cluster — root-caused and closed.** A run hit several compounding wedges at once, each TDD-fixed and verified green: a `main_pm` assigned a `code`-typed task is a structural impossibility (a coordinator PM does no coding) and is now hard-rejected at the gate; `cell_pm_complete` resolved a merge by global `pr_number` and merged the wrong repo's PR (closed by the cross-repo `project_id` scoping above); `submit_root` re-submitted an unchanged PR into an infinite `pr_fail` loop (now hard-gated); `fail_qa` bounced a dev task to the pool instead of back to the dev; a `note(scope='handoff')` with an empty section crashed the note path and tripped a PM respawn loop; the MegaTask four-layer hierarchy (umbrella → root → cell → dev) hit a depth cap sized for three layers; and the durable respawn counter's persist raced under fire-and-forget (an atomic upsert closes it).
@@ -45,6 +47,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **The local LLM was bumped to `glm-5.2` and the Ollama fleet defaults swapped off minimax.** The in-house RAG / hybrid-retrieval model and the default fleet model assignment move to `glm-5.2:cloud`; a stale minimax default that no longer matched the running fleet is cleared.
- **Agent-facing RAG docs and generated prompts readjusted to the post-fix behavior.** The per-task worktree model (F123), the `/app/.venv` is-sacred rule, the PM/code-task invariant, and the `MAX_TASK_DEPTH=4` MegaTask hierarchy are now documented in the RAG corpus (`docs/rag/architecture/workspaces.md`, `workflows/task-claiming.md`, `workflows/git-commits.md`, `workflows/task-planning.md`, `roles/developer.md`) and the generated verb/status tables, so a respawned agent resumes against current guidance instead of the pre-fix model.
## [0.13.0] - 2026-06-26
### Added
+1 -1
View File
@@ -8,7 +8,7 @@
| Verb | Body schema |
|------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` |
| `i_am_idle` | `i_am_idle()` |
+1 -1
View File
@@ -8,7 +8,7 @@
| Verb | Body schema |
|------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` |
+1 -1
View File
@@ -12,7 +12,7 @@
| `give_me_work` | `give_me_work()` |
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` |
| `i_am_idle` | `i_am_idle()` |
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None)` |
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | None = None)` |
| `resume` | `resume(task_id: UUID)` |
| `unclaim` | `unclaim(task_id: UUID)` |
+3 -3
View File
@@ -55,7 +55,7 @@ real tools live in their agent_sdk drivers, not role_config.
| `give_me_work` | `give_me_work()` |
| `i_am_blocked` | `i_am_blocked(task_id: UUID, reason: str, blocker_type: str | None = None, what_needed: str | None = None)` |
| `i_am_idle` | `i_am_idle()` |
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None)` |
| `pass_review` | `pass_review(task_id: UUID, notes: str, ac_verdicts: list[str] | None = None)` |
| `resume` | `resume(task_id: UUID)` |
| `unclaim` | `unclaim(task_id: UUID)` |
@@ -113,7 +113,7 @@ real tools live in their agent_sdk drivers, not role_config.
| Verb | Body schema |
|------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` |
| `i_am_idle` | `i_am_idle()` |
@@ -151,7 +151,7 @@ real tools live in their agent_sdk drivers, not role_config.
| Verb | Body schema |
|------|-------------|
| `complete` | `complete(task_id: UUID, notes: str)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, intends_to_touch: list[str] | BeforeValidator(func=<function coerce_str_list at 0x109dbdee0>, json_schema_input_type=PydanticUndefined) | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `delegate` | `delegate(parent_task_id: UUID, title: str, description: str, assigned_to: str, team: str, task_type: str, nature: str, estimated_complexity: str, acceptance_criteria: list[str], project_id: UUID | None = None, covers_parent_criteria: list[str] | None = None, intends_to_touch: list[str] | None = None, adds_migration: bool = False, touches_shared: bool = False, depends_on: list[UUID] | None = None)` |
| `escalate_to_ceo` | `escalate_to_ceo(task_id: UUID, reason: str)` |
| `escalate_up` | `escalate_up(task_id: UUID, reason: str)` |
| `give_me_work` | `give_me_work()` |
@@ -0,0 +1,43 @@
"""Add archived_by / archived_at to playbooks — distinct retirement attribution.
``archive`` (retire an APPROVED playbook) and ``reject`` (decline a DRAFT) both
end in ARCHIVED, but they are distinct curation acts from ``approve``. Stamping
the archiver into ``approved_by``/``approved_at`` overwrote the approval
provenance (and fabricated approval attribution for a rejected draft that was
never approved). These two columns record who retired it and when, leaving
``approved_by``/``approved_at`` to record only the approval.
Revision ID: 053_playbook_archived_attr
Revises: 052_task_cell_projects
Create Date: 2026-06-30
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "053_playbook_archived_attr"
down_revision = "052_task_cell_projects"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"playbooks",
sa.Column("archived_by", sa.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"playbooks",
sa.Column(
"archived_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("playbooks", "archived_at")
op.drop_column("playbooks", "archived_by")
+6
View File
@@ -19,6 +19,12 @@ Every branch, commit, and pull request carries the task ID it belongs to, so you
A branch is created automatically the moment an agent claims a task, and a **work session** tracks its branch, base, commits, files changed, and pull request from claim to merge.
## Per-task worktrees
An agent's clone is a single shared checkout, but a coordinator PM legitimately holds several in-progress roots at once. So instead of one checkout that each fresh claim `git reset --hard`s onto a new branch — destroying uncommitted work on the still-active first root — **each claimed task gets its own working tree** under the clone at `{clone}/.worktrees/{task-short}/`. The clone keeps the real `.git` object store and the shared `.venv`; each worktree symlinks that `.venv` so tools resolve without a per-task re-sync. The clone's `HEAD` is never moved by a claim, so a second task (or a PM's parallel roots) never overwrites a first task's uncommitted changes.
Branch-by-name operations (`push`, `pull`, `merge`) run from the clone root as before; only the checkout/HEAD-moving operations (`create_branch`, `commit`, `rebase`) target the task's worktree. Exactly one active work session exists per task at a time, so a re-claim supersedes any stale prior session and re-points the worktree at the new claim. When a task reaches a terminal state (completed or cancelled), its worktree is removed best-effort; a task that bounces back through `needs_revision` keeps its worktree to keep working.
## Work converges up a chain
Each developer works in their **own clone** and opens a pull request from their branch. Those flow upward:
+37 -1
View File
@@ -55,9 +55,45 @@ ROBOCO_WORKSPACE_CLONE_TIMEOUT=300
3. **Branch Flexibility**: Different branches simultaneously
4. **Clean State**: Fresh clone if needed
## Per-Task Worktrees (F123)
Your agent clone is **shared across all your tasks**, but each **claimed task** gets its own linked git worktree — a separate working directory on the same underlying clone — so two of your in-progress tasks never clobber each other on one checkout (this is what lets a coordinator PM hold several roots at once, and what stops a fresh claim from `reset --hard`-ing uncommitted work on your still-active first task).
```
{workspaces_root}/{project}/{team}/{agent}/ # the clone root (shared)
├── .git/ # the real object store (shared)
├── .venv/ # the per-project venv (shared, agent-owned)
├── .uv-python/ # uv's managed CPython (shared, gitignored)
└── .worktrees/
└── {task-short}/ # ONE per claimed task — your cwd for that task
├── .venv -> ../../.venv # symlink to the clone-root venv
└── [your task's checked-out branch]
```
- **Claim** (`i_will_work_on` / `claim_review` / `claim_doc_task`) adds a worktree at `.worktrees/{task-id-first-8}/` and checks out the task's branch there. The clone root's HEAD is **never moved** by a claim.
- **Your container is started with `-w` pointing at the worktree** for your current task, so `commit`, edits, and `uv run` all resolve there automatically. Spawn resolves the worktree from your `current_task_id` on every spawn (never cached), so a resume/respawn re-attaches a pruned worktree before launch.
- **The clone-root `.venv` is shared** — each worktree's `.venv` is a symlink to `../../.venv`, so `uv run` from a worktree resolves the clone-root venv. No per-worktree re-sync.
- **Git ops split by kind**: checkout/HEAD-moving ops (`create_branch`, `commit`, `rebase`, `checkout`) target the worktree; branch-by-name ops (`push`, `pull`, `fetch`, `pr_merge`, `diff`) run from the clone root. You never do either by hand — the verbs resolve the worktree for you.
- **One active WorkSession per task** is enforced both in the service layer and by a DB unique index — a re-claim (pool release, reaper unclaim, escalation redirect) supersedes any prior agent's stale session for that task.
- **Claim rollback** (a mid-claim failure) `worktree remove --force`s the worktree so a retry doesn't collide with a stale one.
- **Terminal completion** (`complete` / `ceo_approve`) removes the assignee's worktree best-effort, so finished tasks don't accumulate. A `needs_revision` bounce keeps the worktree — you need it back. The stale-claim reaper does **not** remove the worktree; it routes the task to `pending` for a re-claim that reuses it.
You do not manage any of this. The verbs do. The only thing you must know: **your cwd is the worktree for your current task, not the clone root** — so relative paths and `uv run` resolve against your task's checkout.
## The `/app/.venv` is sacred — never retarget onto it
Two venv classes exist in the container:
- **Workspace venvs** — per-project, agent-owned, under `/data/workspaces/.../{agent}/.venv`. These are yours.
- **`/app/.venv`** — the image-baked MCP-gateway venv. The MCP servers (`roboco-flow`, `roboco-do`, the git-readonly server) import from here. **It is sacred. If it breaks, every tool you have stops spawning.**
A past live incident: an agent hit a permission error on its workspace venv, followed uv's hint to run `uv run --active`, and that retargeted onto `VIRTUAL_ENV=/app/.venv` (baked globally) — uv rebuilt `/app/.venv` from a drifted lock and deleted its `bin/`, bricking every MCP server spawn fleet-wide.
The bash-guard hook now **blocks** `uv run --active` and any `uv run` / `uvx` against `/app` (`--project /app`, `--directory /app`, `UV_PROJECT_ENVIRONMENT=/app`, `cd /app && uv ...`). If you ever feel tempted to use `--active` or point uv at `/app`, **don't** — call `i_am_blocked(reason='workspace venv broken')` instead and let the environment be rebuilt. Bare `uv run` (cwd-relative, your workspace venv) is always fine and always what you want.
## No Workspace Tools — It's Automatic
There are **no** agent-facing workspace tools. Workspaces are created and cloned for you by the orchestrator (`WorkspaceService`) before your container starts. You never `ensure`, `clone`, or `checkout` a workspace by hand — your repo is already on disk at the path below, and the gateway verbs (`i_will_work_on`, `claim_review`, ...) check out the right branch.
There are **no** agent-facing workspace tools. Workspaces and per-task worktrees are created for you by the orchestrator (`WorkspaceService`) before your container starts. You never `ensure`, `clone`, `checkout`, or `worktree add` by hand — your repo is already on disk, the worktree for your current task is already linked and `-w`'d as your cwd, and the gateway verbs (`i_will_work_on`, `claim_review`, ...) check out the right branch in it.
## Workspace Resolution
+3 -1
View File
@@ -231,6 +231,8 @@ Main PM opens the root→master PR and moves the root task to awaiting_pr_review
**Pre side effects:** create_root_pr
**Preconditions:** root_not_code
## submit_up
@@ -251,7 +253,7 @@ Rebase your task's branch onto its current base THROUGH the gate (raw git is den
**Composes:** (no atomic actions)
**Preconditions:** owns_task
**Preconditions:** owns_task, sync_branch_state
## triage
+1
View File
@@ -5,6 +5,7 @@
| awaiting_ceo_approval | cancelled | cancel | cell_pm, ceo, main_pm |
| awaiting_ceo_approval | completed | ceo_approve | ceo |
| awaiting_ceo_approval | needs_revision | ceo_reject | ceo |
| awaiting_ceo_approval | pending | ceo_reject_to_pool | ceo |
| awaiting_documentation | awaiting_pm_review | docs_complete | documenter |
| awaiting_documentation | cancelled | cancel | cell_pm, ceo, main_pm |
| awaiting_documentation | claimed | claim | documenter |
+2 -1
View File
@@ -67,9 +67,10 @@ There is **no** `roboco_git_commit / _push / _create_pr / _merge_pr / _checkout`
## Branch Discipline
- Branches are auto-created on `i_will_work_on()`.
- Branches are auto-created on `i_will_work_on()`, and each claimed task gets its own **per-task worktree** (your cwd for that task). See `docs/rag/architecture/workspaces.md`.
- Don't checkout branches by hand — call the verb on the right task.
- A drifted clone (after a respawn/resume) is now auto-recovered onto your task branch before you commit — you normally won't see `BRANCH_MISMATCH` at all. If you still do, uncommitted changes are blocking the switch: `commit(...)` your work (or `i_am_blocked` if the changes aren't yours), then continue.
- Never run `uv run --active` or point uv at `/app` — it bricks the MCP-gateway venv. Bare `uv run` resolves your workspace venv. If the workspace venv is broken, `i_am_blocked(reason='workspace venv broken')`.
## Before Submitting to QA
+6 -3
View File
@@ -32,9 +32,10 @@ This automatically:
1. Prefixes the commit with `[task-id-first-8-chars]`
2. Validates the message via `commit_validator`
3. Stages the listed files (or everything tracked + modified if omitted)
4. Pushes to the agent's auto-created branch
5. Records the commit on the task (`commits[]` field on `TaskTable`)
6. Opens a PR through the choreographer when the task transitions out of `in_progress` (no separate `create_pr` call required)
4. Commits **inside your task worktree** (`{clone_root}/.worktrees/{task-id-first-8}/`) — your cwd, never the clone root
5. Pushes the task's recorded branch **by name** (independent of whatever the clone happens to be checked out on)
6. Records the commit on the task (`commits[]` field on `TaskTable`)
7. Opens a PR through the choreographer when the task transitions out of `in_progress` (no separate `create_pr` call required)
## Before Committing
@@ -43,6 +44,8 @@ This automatically:
3. Run type check: `uv run mypy roboco/` or `pnpm typecheck`
4. Format code: `uv run ruff format .` or `pnpm format`
**Never use `uv run --active` or point uv at `/app`.** Bare `uv run` is cwd-relative and resolves your workspace venv (symlinked into the worktree) — that's always what you want. `--active` retargets onto the image-baked `/app/.venv` (the MCP-gateway venv) and rebuilds it, bricking every tool you have. The bash-guard blocks it; if you ever feel pushed toward `--active`, call `i_am_blocked(reason='workspace venv broken')` instead. See `docs/rag/architecture/workspaces.md`.
## After Committing
You don't push or create a PR yourself. The choreographer pushed the commit during `commit()`, and the PR is opened/merged as part of the lifecycle transitions:
+3 -1
View File
@@ -25,7 +25,9 @@ claim_doc_task(task_id) # Documenter
# - assigned_to: your agent ID
```
The claim verb both claims and starts the task — there is no separate `start` call. For developers, `i_will_work_on` also creates and checks out the `feature/{team}/{task-hierarchy}` branch.
The claim verb both claims and starts the task — there is no separate `start` call. For developers, `i_will_work_on` also creates the `feature/{team}/{task-hierarchy}` branch and **adds a dedicated per-task worktree** at `{clone_root}/.worktrees/{task-id-first-8}/`, checking out the branch there. Your container is started with that worktree as its cwd, and the clone root's HEAD is never moved by the claim — so a second claim (or a coordinator PM's many parallel roots) never overwrites your first task's uncommitted work. See `docs/rag/architecture/workspaces.md` for the worktree model.
Exactly one active WorkSession exists per task at a time (enforced in the service layer and by a DB unique index). A re-claim — pool release, reaper unclaim, escalation redirect — supersedes any prior agent's stale active session for that task and re-points the worktree at the new claim.
## Before Claiming
+14
View File
@@ -71,6 +71,20 @@ For code subtasks, delegate each developer their **full queue up front** rather
Caps still apply: at most 12 subtasks per parent, and same-title duplicate subtasks are rejected.
## PMs Do Not Own Code Tasks
A PM (Cell PM or Main PM) is a coordinator — it plans and delegates, it does not write code, and it has no code verb. The role×task_type rule is enforced at **creation**, not just at delegate:
- A `code`-typed task **cannot be assigned to a PM**`delegate`, `TaskService.create`, batch activation, reassign, and the claim/escalation diversion all consult the same `pm_cannot_own_code` / `main_pm_cannot_own_code` guard and reject it.
- A Main-PM coordination **root** that is `code`-typed is rejected by `submit_root`'s `PRECONDITION_ROOT_NOT_CODE` — a Main PM can never assemble+merge a code root, because it can't have written one.
- The one exception — "a PM may take a code task **only to resolve review issues**" (`is_issue_resolution`) — is a server-side signal the platform sets when routing a `needs_revision` code task back to its owning PM to act on concrete review issues; it is **not** something you pass from a verb. In practice no live path exercises it yet; the structural rule is: if you're a PM and you're looking at a `code` task, delegate it to a developer instead.
This is structural, not a hint. Before this guard, a PM assigned a code task would claim it and deadlock into a respawn loop — a coordinator with no code verb holding a code task it can neither do nor hand back. The guard makes that loop unrepresentable.
## Delegation Depth
The task hierarchy is capped at `MAX_TASK_DEPTH = 4` levels (depths 03). The normal 3-layer flow (Main-PM root → cell task → dev subtask) fits in 3; **MegaTask** adds one Main-PM layer on top — umbrella (depth 0) → root-subtask (1) → cell task (2) → dev subtask (3) — which is why the cap is 4, not 3. A `delegate` that would create a node at depth 4 is rejected with a clean `invalid_state` and a "create as a sibling" remediation. Don't over-nest; if you're hitting the cap, the work belongs as a sibling, not a child.
## Git Workflow
All code tasks follow the git workflow:
+8
View File
@@ -444,6 +444,14 @@
"source": "awaiting_ceo_approval",
"target": "needs_revision"
},
{
"action": "ceo_reject_to_pool",
"roles": [
"ceo"
],
"source": "awaiting_ceo_approval",
"target": "pending"
},
{
"action": "docs_complete",
"roles": [
@@ -151,7 +151,7 @@ describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
expect(mutateAsync).toHaveBeenCalledWith({
taskId: "t1",
updates: { status: TaskStatus.COMPLETED },
updates: { status: TaskStatus.COMPLETED, force: true },
});
});
@@ -184,7 +184,7 @@ describe("KanbanBoard — admin-override bypass confirmation (F020)", () => {
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
expect(mutateAsync).toHaveBeenCalledWith({
taskId: "t1",
updates: { status: TaskStatus.CLAIMED },
updates: { status: TaskStatus.CLAIMED, force: false },
});
// No bypass confirmation should ever have been surfaced.
expect(screen.queryByText(/override & move/i)).not.toBeInTheDocument();
@@ -54,6 +54,16 @@ interface PendingOverride {
skipped: string[];
}
// Terminal/hatch states the server-side PATCH override guard refuses without
// an explicit `force: true` (the bypass is deliberate + audited). A kanban
// drag is the admin override surface, so a move into one of these must carry
// the acknowledgement or the backend rejects with 400.
const HATCH_OVERRIDE_STATES: ReadonlySet<TaskStatus> = new Set([
TaskStatus.COMPLETED,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_PM_REVIEW,
]);
interface ColumnConfig {
id: string;
status: TaskStatus;
@@ -164,7 +174,10 @@ export function KanbanBoard({
try {
await updateTask.mutateAsync({
taskId,
updates: { status: newStatus },
updates: {
status: newStatus,
force: HATCH_OVERRIDE_STATES.has(newStatus),
},
});
toast.success(`Task moved to ${newStatus.replace(/_/g, " ")}`);
refetch();
@@ -179,7 +192,10 @@ export function KanbanBoard({
try {
await updateTask.mutateAsync({
taskId: task.id,
updates: { status: newStatus },
updates: {
status: newStatus,
force: HATCH_OVERRIDE_STATES.has(newStatus),
},
});
toast.success(`Task moved to ${newStatus.replace(/_/g, " ")}`);
refetch();
+3 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useUIStore } from "@/lib/stores/ui-store";
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store";
/**
* Global scroll restoration component.
@@ -12,7 +12,8 @@ import { useUIStore } from "@/lib/stores/ui-store";
export function ScrollRestoration() {
const pathname = usePathname();
const searchParams = useSearchParams();
const { setScrollPosition, getScrollPosition, setLastVisited } = useUIStore();
const { setScrollPosition, getScrollPosition, setLastVisited } =
useScrollRestorationStore();
const hasRestored = useRef(false);
const prevRouteKey = useRef<string>("");
@@ -46,6 +46,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
"Close the learn→reuse loop: distill a lesson at task completion, index journal reflections, and auto-inject similar past lessons + approved playbooks into an agent's briefing on claim.",
routing_strict:
"Fail-closed model routing: refuse to silently downgrade an agent to the legacy Anthropic path when its configured provider is disabled (raise instead). Off => graceful degradation with a warning.",
};
export function FeatureFlagsCard() {
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react";
import { useEffect } from "react";
import type {
ConnectionState,
WebSocketOptions,
} from "@/lib/websocket/connection";
// #79: useWebSocket's effect cleanup only disconnected the socket — it left the
// `messages` / `lastMessage` / `state` snapshot behind. On a dependency change
// (navigating from one agent/channel stream to another) the new subscription
// inherited the previous stream's stale buffer until a fresh frame arrived.
// Mock the connection so the test can drive onMessage/onStateChange and observe
// the cleanup.
const hoisted = vi.hoisted(() => {
const instances: MockConnection[] = [];
class MockConnection {
url: string;
onMessage?: (data: unknown) => void;
onStateChange?: (state: ConnectionState) => void;
didConnect = false;
didDisconnect = false;
constructor(opts: WebSocketOptions) {
this.url = opts.url;
this.onMessage = opts.onMessage;
this.onStateChange = opts.onStateChange;
instances.push(this);
}
connect() {
this.didConnect = true;
this.onStateChange?.("connecting");
this.onStateChange?.("connected");
}
disconnect() {
this.didDisconnect = true;
this.onStateChange?.("disconnected");
}
}
return { instances, MockConnection };
});
vi.mock("@/lib/websocket/connection", () => ({
getWebSocketUrl: () => "ws://test/ws",
WebSocketConnection: hoisted.MockConnection,
}));
vi.mock("@/lib/constants", () => ({
CEO_AGENT_ID: "00000000-0000-0000-0000-000000000001",
STREAM_MAX_MESSAGES: 100,
}));
import { useWebSocket } from "../use-websocket";
interface Frame {
type: "agent.stream";
agent_id: string;
chunk: string;
}
// Capture the hook's latest return into a shared ref object (mutation, not
// reassignment — the react-hooks/globals rule forbids the latter in render).
const resultRef: {
current: ReturnType<typeof useWebSocket<Frame>> | null;
} = { current: null };
function Harness({ endpoint }: { endpoint: string }) {
const ws = useWebSocket<Frame>(endpoint, undefined, true);
// Capture the latest return after each render via a passive effect (keeps
// render pure — the react-hooks rules forbid mutating shared state in render).
useEffect(() => {
resultRef.current = ws;
});
return null;
}
describe("useWebSocket — clears snapshot on cleanup (#79)", () => {
beforeEach(() => {
hoisted.instances.length = 0;
resultRef.current = null;
});
afterEach(() => {
vi.clearAllMocks();
});
it("clears messages/lastMessage/state when the endpoint changes (no stale leak)", () => {
// 1. Subscribe to stream A and receive a frame.
const { rerender } = render(<Harness endpoint="/agents/a" />);
const connA = hoisted.instances[0];
expect(connA.didConnect).toBe(true);
act(() => {
connA.onMessage?.({
type: "agent.stream",
agent_id: "a",
chunk: "hello",
});
});
expect(resultRef.current?.messages).toHaveLength(1);
expect(resultRef.current?.lastMessage?.chunk).toBe("hello");
expect(resultRef.current?.isConnected).toBe(true);
// 2. Navigate to stream B — the cleanup for A runs, then B connects. Before
// the fix the buffer from A survived into B's subscription.
act(() => {
rerender(<Harness endpoint="/agents/b" />);
});
expect(connA.didDisconnect).toBe(true);
expect(hoisted.instances).toHaveLength(2);
expect(hoisted.instances[1].url).toContain("/agents/b");
// The stale snapshot MUST be cleared.
expect(resultRef.current?.messages).toEqual([]);
expect(resultRef.current?.lastMessage).toBeNull();
});
it("disconnects the connection on unmount", () => {
const { unmount } = render(<Harness endpoint="/agents/a" />);
const conn = hoisted.instances[0];
unmount();
expect(conn.didDisconnect).toBe(true);
});
});
+2 -2
View File
@@ -8,14 +8,14 @@
import { useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useUIStore } from "@/lib/stores/ui-store";
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store";
export function useScrollRestoration(
scrollContainerRef?: React.RefObject<HTMLElement>,
) {
const pathname = usePathname();
const searchParams = useSearchParams();
const { setScrollPosition, getScrollPosition } = useUIStore();
const { setScrollPosition, getScrollPosition } = useScrollRestorationStore();
// Create a unique key for current route including search params
const routeKey = `${pathname}?${searchParams.toString()}`;
+5 -2
View File
@@ -12,8 +12,11 @@ import {
type EscalateRequest,
} from "@/types";
// Type for task updates - allows any Task field to be updated
export type TaskUpdate = Partial<Task>;
// Type for task updates - allows any Task field to be updated. `force` is not a
// Task field: it acknowledges that a kanban admin-override into a hatch state
// (completed / awaiting_qa / awaiting_pm_review) bypasses the lifecycle gate,
// matching the server-side PATCH override guard.
export type TaskUpdate = Partial<Task> & { force?: boolean };
// Query keys
export const taskKeys = {
+7 -1
View File
@@ -90,10 +90,16 @@ export function useWebSocket<T>(
connectionRef.current = connection;
connection.connect();
// Cleanup on unmount or when dependencies change
// Cleanup on unmount or when dependencies change. Disconnect AND clear the
// snapshot — otherwise a dep change (navigating to another stream) leaves
// the prior subscription's messages/lastMessage/state visible until a fresh
// frame arrives, surfacing another stream's stale buffer as live (#79).
return () => {
connection.disconnect();
connectionRef.current = null;
setMessages([]);
setLastMessage(null);
setState("disconnected");
};
}, [enabled, endpoint, queryString]); // Stable dependencies
+6 -2
View File
@@ -86,8 +86,12 @@ export const prompterLiveApi = {
return data;
},
/** SSE URL the panel opens to watch the agent. EventSource sends no headers
* (the route is keyed by the opaque session id on the trusted network). */
/** SSE URL the panel opens to watch the agent. EventSource cannot send custom
* headers, so the live-intake stream carries no `X-Agent-*` auth the route
* is authenticated solely by the opaque, unguessable session id on the
* trusted internal network. By design: any session-id leakage grants stream
* access, so session ids must be treated as bearer credentials (never logged
* client-side, never put in a shareable URL). */
streamUrl: (sessionId: string): string =>
`${API_URL}/prompter/live/${sessionId}/stream`,
+1 -1
View File
@@ -1 +1 @@
export { useUIStore } from "./ui-store";
export { useScrollRestorationStore } from "./scroll-restoration-store";
@@ -1,8 +1,10 @@
/**
* UI State Store
* Scroll-restoration / session-navigation store.
*
* Persists UI state across navigation using Zustand with sessionStorage.
* This handles state that doesn't belong in URL params but should survive navigation.
* Persists scroll position and last-visited-route state across navigation using
* Zustand with sessionStorage state that doesn't belong in URL params but
* should survive navigation. Renamed from the generic `useUIStore` to avoid a
* name clash with the sidebar/theme UI store in `@/store`.
*/
import { create } from "zustand";
@@ -13,7 +15,7 @@ interface ScrollPosition {
y: number;
}
interface UIState {
interface ScrollRestorationState {
// Scroll positions per route
scrollPositions: Record<string, ScrollPosition>;
@@ -42,7 +44,7 @@ interface UIState {
getLastVisited: (section: string) => string | undefined;
}
export const useUIStore = create<UIState>()(
export const useScrollRestorationStore = create<ScrollRestorationState>()(
persist(
(set, get) => ({
scrollPositions: {},
+4 -10
View File
@@ -340,16 +340,10 @@ def get_pm_for_agent(agent_id: str) -> str | None:
# Cross-cell roles (MAIN_PM, AUDITOR, CEO, board) are not subject to team_scope;
# only cell-member roles (DEVELOPER/QA/DOCUMENTER/CELL_PM) are filtered.
# Cell-member roles subject to team_scope filtering. Lifted to module scope so
# tests and downstream consumers can introspect the rule.
_TEAM_SCOPED_ROLES: Final[frozenset[_foundation.Role]] = frozenset(
{
_foundation.Role.DEVELOPER,
_foundation.Role.QA,
_foundation.Role.DOCUMENTER,
_foundation.Role.CELL_PM,
}
)
# Cell-member roles subject to team_scope filtering. Single-sourced in
# ``foundation.policy.communications.TEAM_SCOPED_ROLES``; re-exported here so
# tests and downstream consumers can introspect the rule under the legacy name.
_TEAM_SCOPED_ROLES: Final[frozenset[_foundation.Role]] = _comms.TEAM_SCOPED_ROLES
def _slugs_for_role_set(
+16
View File
@@ -409,6 +409,22 @@ def require_pm_or_above(role: Any, action: str) -> None:
)
def require_ceo_role(role: Any, *, action: str = "perform this action") -> None:
"""Raise 403 unless ``role`` is the CEO (#25 — the single CEO-check).
The orchestrator router-level gate (header + HMAC token) and the release
handler-level gate (``CurrentAgentContext``) had drifted apart on the role
comparison; both now delegate here so the CEO check has one source of
truth. Accepts an ``AgentRole`` / ``Role`` enum or the lowercase header
string. ``action`` tailors the 403 detail message.
"""
if _role_value(role) != Role.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Only the CEO may {action}",
)
def require_developer_or_above(role: Any, action: str) -> None:
"""Raise 403 unless caller is developer-or-above."""
if _role_value(role) not in _DEVELOPER_OR_ABOVE_ROLES:
+1
View File
@@ -97,6 +97,7 @@ async def write_doc(
created_at=doc_ref.created_at,
updated_by=doc_ref.updated_by,
updated_at=doc_ref.updated_at,
commit_status=doc_ref.commit_status,
),
)
except ValidationError as e:
+10 -13
View File
@@ -9,7 +9,12 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from roboco.api.deps import _check_agent_auth_token, get_orchestrator, set_orchestrator
from roboco.api.deps import (
_check_agent_auth_token,
get_orchestrator,
require_ceo_role,
set_orchestrator,
)
from roboco.api.schemas.orchestrator import (
AgentStatusResponse,
OrchestratorStatusResponse,
@@ -17,7 +22,7 @@ from roboco.api.schemas.orchestrator import (
SpawnAgentRequest,
WaitingAgentResponse,
)
from roboco.foundation.identity import Role
# Orchestrator control routes (spawn / stop / resolve-wait / mark-waiting,
# plus the read-only status views) are operator/CEO control surfaces — any
@@ -30,10 +35,8 @@ from roboco.foundation.identity import Role
# the same contract as the v1 flow role guards and the do router. CEO is the
# sole operator role; agents (developers/QA/PMs) drive the orchestrator via
# MCP verbs, not these HTTP routes, so a developer token is correctly 403'd
# here.
_CEO_ROLE = Role.CEO.value
# here. The CEO role check itself delegates to ``require_ceo_role`` (#25 —
# the single source of truth shared with the release routes).
def _require_ceo(
x_agent_id: Annotated[str, Header(alias="X-Agent-ID")],
x_agent_role: Annotated[str, Header(alias="X-Agent-Role")],
@@ -43,13 +46,7 @@ def _require_ceo(
# Bind the role header to a verified token BEFORE trusting it (same
# defense-in-depth contract as the v1 flow role guards in _role_dep.py).
_check_agent_auth_token(x_agent_id, x_agent_role, x_agent_team, x_agent_token)
# ``Role`` is a StrEnum so the lowercase header string compares equal to
# its matching member.
if x_agent_role.lower() != _CEO_ROLE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the CEO/operator may control the orchestrator",
)
require_ceo_role(x_agent_role, action="control the orchestrator")
router = APIRouter(dependencies=[Depends(_require_ceo)])
+7 -20
View File
@@ -200,26 +200,13 @@ async def update_project(
require_cell_access(agent, project.assigned_cell, "update")
# Convert request to service model
update_data = ProjectUpdate(
name=data.name,
git_url=data.git_url,
default_branch=data.default_branch,
protected_branches=data.protected_branches,
assigned_cell=data.assigned_cell,
git_token=data.git_token,
test_command=data.test_command,
lint_command=data.lint_command,
format_command=data.format_command,
typecheck_command=data.typecheck_command,
build_command=data.build_command,
quality_command=data.quality_command,
ci_watch_enabled=data.ci_watch_enabled,
ci_watch_workflow=data.ci_watch_workflow,
dep_update_command=data.dep_update_command,
dep_update_paths=data.dep_update_paths,
is_active=data.is_active,
)
# Convert request to service model, preserving the request's own
# unset-tracking: only fields the body actually carried are "set" on the
# ProjectUpdate, so ProjectService.update's exclude_unset applies only the
# body fields — and an explicit null in the body clears the stored value
# (distinct from absent = leave unchanged) (#197). Passing every field
# explicitly here would mark them all set and defeat that distinction.
update_data = ProjectUpdate.model_validate(data.model_dump(exclude_unset=True))
updated = await service.update(cast("UUID", project.id), update_data)
await db.commit()
+2 -7
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, cast
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.release import (
ReleaseExecuteResponse,
ReleaseGapModel,
@@ -18,7 +18,6 @@ from roboco.api.schemas.release import (
ReleaseReportModel,
)
from roboco.foundation.policy.content import markers
from roboco.models import AgentRole
from roboco.services.release_proposal import get_release_proposal_service
if TYPE_CHECKING:
@@ -30,11 +29,7 @@ router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the CEO may view or act on release proposals",
)
require_ceo_role(agent.role, action="view or act on release proposals")
def _status_value(task: "TaskTable") -> str:
+80 -16
View File
@@ -4,6 +4,7 @@ Task API Routes
Full CRUD operations and lifecycle management for tasks.
"""
from dataclasses import dataclass
from typing import Annotated, Any, cast
from uuid import UUID
@@ -45,6 +46,7 @@ from roboco.api.schemas.tasks import (
task_to_response,
transform_update_data,
)
from roboco.db.tables import TaskTable
from roboco.enforcement import get_valid_transitions
from roboco.exceptions import GitError, TaskLifecycleError
from roboco.foundation.policy import task_completeness as tc
@@ -64,10 +66,11 @@ from roboco.services.notification_delivery import (
EscalationError,
get_notification_delivery_service,
)
from roboco.services.permissions import TaskAction
from roboco.services.permissions import AgentContext, TaskAction
from roboco.services.task import (
SoftBlockInput,
TaskCreateRequest,
TaskService,
extract_original_developer,
get_task_service,
)
@@ -76,6 +79,69 @@ from roboco.utils.converters import require_uuid
router = APIRouter()
_logger = get_logger(__name__)
# #13: lifecycle-bypass hatch states — a privileged PATCH into one of these is a
# forced override that must carry the explicit ``force`` acknowledgement flag.
_HATCH_OVERRIDE_STATES = frozenset(
{
TaskStatus.COMPLETED,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_PM_REVIEW,
}
)
@dataclass(frozen=True, slots=True)
class _StatusOverride:
"""Bundle of ``update_task`` override params (keeps the helper ≤ 5 args)."""
service: TaskService
task_id: UUID
task: TaskTable
new_status: TaskStatus
force: bool
has_higher_perms: bool
agent: AgentContext
async def _apply_forced_status_override(req: _StatusOverride) -> TaskTable:
"""Apply an audited admin status override, gating the lifecycle bypass.
Extracted from ``update_task`` so the route's complexity stays readable.
Refuses a non-privileged caller, and refuses a bypass into a hatch state
without the explicit ``force`` flag; otherwise delegates to the audited
``admin_set_status`` and asserts the override landed.
"""
if req.new_status == req.task.status:
return req.task
if not req.has_higher_perms:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only privileged roles may override task status.",
)
if req.new_status in _HATCH_OVERRIDE_STATES and not req.force:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Overriding a task into "
f"{req.new_status.value} bypasses the lifecycle gate; pass "
'"force": true to acknowledge the forced override.'
),
)
task = await req.service.admin_set_status(
req.task_id,
req.new_status,
actor_id=req.agent.agent_id,
actor_role=getattr(req.agent, "role", None),
force=req.force,
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task status override failed unexpectedly",
)
return task
# Minimum character count for notes fields that must be substantive
# (QA pass notes, doc-complete notes, escalation notes). Below this the
# note is useless for the next reader, so the transition is refused.
@@ -892,6 +958,9 @@ async def update_task(
# in-band transition. Pop it out of the generic field update and apply it
# through the audited path, gated on elevated permissions.
new_status = updates.pop("status", None)
# #13: ``force`` is the explicit acknowledgement of the lifecycle bypass.
# Pop it so it is never passed to TaskService.update as a field set.
force = bool(updates.pop("force", False))
# Pop explicitly-set-to-None nullable fields. TaskService.update() skips
# None values (not-None guard), so null-clear intent is re-applied directly
@@ -909,23 +978,18 @@ async def update_task(
# the MegaTask shape here too — a cleared parent_task_id / project_id must not
# turn a root-subtask into an umbrella-shaped-but-targeted spoof.
_reassert_batch_shape(task)
if new_status is not None and new_status != task.status:
if not has_higher_perms:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only privileged roles may override task status.",
if new_status is not None:
task = await _apply_forced_status_override(
_StatusOverride(
service=service,
task_id=task_id,
task=task,
new_status=new_status,
force=force,
has_higher_perms=has_higher_perms,
agent=agent,
)
task = await service.admin_set_status(
task_id,
new_status,
actor_id=agent.agent_id,
actor_role=getattr(agent, "role", None),
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task status override failed unexpectedly",
)
await db.commit()
return task_to_response(task)
+1
View File
@@ -90,6 +90,7 @@ class DocRefResponse(BaseModel):
created_at: str | None = None
updated_by: str | None = None
updated_at: str | None = None
commit_status: str | None = None
model_config = ConfigDict(from_attributes=True)
+7
View File
@@ -250,6 +250,13 @@ class TaskUpdate(BaseModel):
# state with no valid in-band move), never as a free-form field set.
status: TaskStatus | None = None
# #13: an explicit acknowledgement that the override pastes over the
# lifecycle gate. Required for the terminal/final hatch states
# (completed / awaiting_qa / awaiting_pm_review) so the bypass is deliberate
# and audited as a forced override, not a free-form status set. Other
# recovery overrides (e.g. blocked -> pending) do not require it.
force: bool = False
@model_validator(mode="before")
@classmethod
def _reject_explicit_blank_acceptance_criteria(cls, data: Any) -> Any:
+9 -2
View File
@@ -611,9 +611,16 @@ async def system_stream(websocket: WebSocket) -> None:
Carries system-level events for the control panel currently the
rate-limit lifecycle (``RATE_LIMIT_HIT`` / ``RATE_LIMIT_LIFTED``), bridged
from the event bus by ``websocket_bridge``. No per-agent keying or auth:
it's a read-only operator stream behind the panel's own access controls.
from the event bus by ``websocket_bridge``. No per-agent keying; the
panel/CEO token gate matches every sibling /ws/* stream (#24 — this was the
only ungated /ws endpoint): in strict mode a missing CEO token closes with
policy-violation, a presented-but-forged token is rejected even in dev.
"""
# Verify the panel/CEO token before subscribing — same gate as every other
# /ws/* handler (#24).
if not await _require_panel_token(websocket):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await manager.connect_system(websocket)
try:
+2 -2
View File
@@ -3,6 +3,6 @@
Provides token-cost calculation for Claude API models.
"""
from roboco.billing.pricing import calculate_cost
from roboco.billing.pricing import CostResult, calculate_cost, calculate_cost_result
__all__ = ["calculate_cost"]
__all__ = ["CostResult", "calculate_cost", "calculate_cost_result"]
+59 -8
View File
@@ -24,6 +24,8 @@ against missing pricing data.
from __future__ import annotations
from dataclasses import dataclass
import structlog
logger = structlog.get_logger(__name__)
@@ -90,6 +92,24 @@ def _lookup_prices(lower: str) -> tuple[float, float, float, float] | None:
return best_prices
@dataclass(frozen=True)
class CostResult:
"""Estimated cost plus pricing attribution (#65).
``cost_usd`` is ``0.0`` for both a genuinely-free non-Anthropic model (local
inference no per-token cost) and an unpriced Anthropic model (a Claude
model we forgot to price real spend we are failing to count). ``unpriced``
distinguishes them so a caller can surface the miss instead of silently
reporting ``$0``. ``is_anthropic`` records which family the model resolved
to. ``calculate_cost`` returns just the ``cost_usd`` float for existing
callers; ``calculate_cost_result`` returns the full attribution.
"""
cost_usd: float
unpriced: bool
is_anthropic: bool
def calculate_cost(
model: str,
tokens_input: int,
@@ -99,11 +119,34 @@ def calculate_cost(
) -> float:
"""Calculate the estimated USD cost for a model invocation.
Thin wrapper over :func:`calculate_cost_result` returning just the USD
float (kept for existing callers). See :func:`calculate_cost_result` for the
provider-aware miss handling and the ``unpriced`` attribution.
"""
return calculate_cost_result(
model,
tokens_input=tokens_input,
tokens_output=tokens_output,
tokens_cache_read=tokens_cache_read,
tokens_cache_write=tokens_cache_write,
).cost_usd
def calculate_cost_result(
model: str,
tokens_input: int,
tokens_output: int,
tokens_cache_read: int = 0,
tokens_cache_write: int = 0,
) -> CostResult:
"""Calculate the estimated USD cost for a model invocation, with attribution.
Matches the model name against the known pricing table using substring
search (longest match wins). Provider-aware (see module docstring):
non-Anthropic models (local Ollama, Ollama Cloud) have no per-token cost
and return 0.0 silently; an unpriced Anthropic model returns 0.0 but logs
a warning since it represents real spend we are failing to count.
and return ``cost_usd=0.0, unpriced=False``; an unpriced Anthropic model
returns ``cost_usd=0.0, unpriced=True`` and logs a warning since it
represents real spend we are failing to count.
Args:
model: Model name or short alias (e.g. ``"claude-sonnet-4-6"``,
@@ -118,24 +161,30 @@ def calculate_cost(
``grok_cli_usage.usage_and_cost``).
Returns:
Estimated cost in USD as a float. Returns 0.0 for unpriced models
rather than raising.
A :class:`CostResult` (``cost_usd``, ``unpriced``, ``is_anthropic``).
``cost_usd`` is ``0.0`` rather than raising for unpriced models.
"""
if not model:
return 0.0
# An empty model name is a caller bug, not an unpriced-Anthropic miss.
return CostResult(cost_usd=0.0, unpriced=False, is_anthropic=False)
lower = model.lower()
is_anthropic = _is_anthropic_model(lower)
best_prices = _lookup_prices(lower)
if best_prices is None:
# No per-token rate. Warn only for Anthropic models (real spend we are
# undercounting); non-Anthropic models are local/subscription-billed
# and have no per-token cost, so an intentional 0.0 is correct.
if _is_anthropic_model(lower):
if is_anthropic:
logger.warning("No pricing data found for Anthropic model", model=model)
else:
logger.debug("Non-Anthropic model has no per-token cost", model=model)
return 0.0
# Unpriced only when it is an Anthropic model we forgot to price; a
# non-Anthropic model with no rate is intentionally free.
return CostResult(
cost_usd=0.0, unpriced=is_anthropic, is_anthropic=is_anthropic
)
inp_price, out_price, cr_price, cw_price = best_prices
@@ -145,4 +194,6 @@ def calculate_cost(
+ tokens_cache_read * cr_price / _MILLION
+ tokens_cache_write * cw_price / _MILLION
)
return round(cost, 8)
return CostResult(
cost_usd=round(cost, 8), unpriced=False, is_anthropic=is_anthropic
)
+38
View File
@@ -175,6 +175,18 @@ class Settings(BaseSettings):
description="Base URL for Ollama native API (embeddings, model mgmt)",
)
routing_strict: bool = Field(
default=False,
description=(
"Fail-closed model routing: when an agent has a configured "
"model_assignment whose provider is disabled (or otherwise "
"unroutable), raise instead of silently downgrading to the "
"legacy Anthropic path. Off (default) => graceful degradation "
"with a warning, so a misconfigured provider never stalls a "
"spawn; the warning still surfaces the bypass so it isn't silent."
),
)
# ==========================================================================
# Agent runtime toolchain matching (default-off)
# ==========================================================================
@@ -476,6 +488,17 @@ class Settings(BaseSettings):
ge=1,
description="Max self-heal fix tasks the loop may originate in one cycle.",
)
self_heal_notify_dedupe_seconds: int = Field(
default=7200,
ge=60,
description=(
"Per-fingerprint CEO-notify dedupe window. A regression that stays"
" red across cycles notifies the CEO once per episode, not every"
" tick; the dedupe key expires after this window so a regression"
" that clears and later recurs notifies again. Fail-open: a Redis"
" outage in the check still lets the notify through."
),
)
# Multi-repo CI-watch — generalizes the single-repo self-heal CI loop to any
# opted-in project (per-project `ci_watch_enabled` column). Default-off;
@@ -893,6 +916,21 @@ class Settings(BaseSettings):
"override via ROBOCO_GROK_IDLE_KILL_SECONDS"
),
)
# A non-GROK agent (Claude / Ollama-cloud / etc.) that gets stuck in a
# non-verb loop — alive but firing no gateway verb, so its heartbeat never
# advances and the reaper's live-container skip shields its claim forever
# (#73). Past this MUCH longer window the orchestrator kills + evicts the
# container so the reaper releases the task. Deliberately far beyond any
# legit edit/test cycle (a working agent fires gateway verbs every few
# minutes) so only a truly-stuck run trips it, never a slow-but-working one.
claude_stuck_kill_seconds: int = Field(
default=3600,
ge=600,
description=(
"Stuck-in-non-verb-loop kill threshold for non-GROK agents "
"(seconds); override via ROBOCO_CLAUDE_STUCK_KILL_SECONDS"
),
)
# Budget kill-switch parity for GROK. Claude Code's per-agent token-budget
# hook fires against the SDK :9000 server; the grok CLI exposes no live usage
# hook, so the orchestrator enforces the cap by reading each live GROK
+14
View File
@@ -27,6 +27,20 @@ if TYPE_CHECKING:
# plain ``.ts``. Map each dialect tag to the language family it belongs to.
_DIALECT_OF: dict[str, str] = {"tsx": "typescript"}
# Languages the validator actually reports (runner._LANGUAGE_BY_SUFFIX). A
# custom rule scoped to anything else is a typo — the rule would silently
# never fire, so ``unrecognized_rule_languages`` surfaces it fail-loud (#129).
# The tsx -> typescript dialect relation above is intentional (#32 BY-DESIGN).
_KNOWN_LANGUAGES: frozenset[str] = frozenset({"python", "typescript", "tsx"})
def unrecognized_rule_languages(rule: CustomRule) -> list[str]:
"""Language tags on ``rule`` the validator never reports — likely a typo.
An unscoped rule (empty ``languages``) applies to everything and is valid.
"""
return [lang for lang in rule.languages if lang not in _KNOWN_LANGUAGES]
def _rule_applies(rule_languages: list[str], file_language: str) -> bool:
"""Whether a custom rule scoped to ``rule_languages`` applies to a file of
+2
View File
@@ -47,6 +47,8 @@ _DB_METHODS = frozenset(
"execute",
"scalar",
"scalars",
"stream",
"stream_scalars",
"add",
"add_all",
"merge",
+31 -3
View File
@@ -11,7 +11,8 @@ from pathlib import Path
from typing import TYPE_CHECKING
from . import classify_python, classify_ts
from .custom import check_custom
from .custom import check_custom, unrecognized_rule_languages
from .findings import Finding
from .grammars import GrammarUnavailable
from .hygiene import check_hygiene
from .modularity import check_modularity
@@ -20,11 +21,15 @@ from .placement import check_placement
if TYPE_CHECKING:
from roboco.foundation.policy.conventions.models import ConventionsStandard
from .findings import Finding
from .placement import Definition
_LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "tsx"}
# Surfaced on the conventions file itself — a custom rule scoped to a language
# the validator never reports (a typo) would silently never fire (#129).
_LANGUAGE_SCOPE_RULE = "custom_language_scope"
_LANGUAGE_SCOPE_FILE = ".roboco/conventions.yml"
class ValidatorCouldNotRun(RuntimeError):
"""Raised when the validator cannot analyze a file (fail-loud signal)."""
@@ -39,7 +44,7 @@ def run(
) -> list[Finding]:
"""Check ``files`` (repo-relative) under ``root`` against ``standard``."""
root_path = Path(root)
findings: list[Finding] = []
findings: list[Finding] = list(_language_scope_findings(standard))
for rel in files:
language = _LANGUAGE_BY_SUFFIX.get(Path(rel).suffix)
if language is not None:
@@ -47,6 +52,29 @@ def run(
return _apply_waivers(findings, standard)
def _language_scope_findings(standard: ConventionsStandard) -> list[Finding]:
"""Warn-once per custom rule scoped to a language the validator never reports."""
out: list[Finding] = []
for rule in standard.custom:
for tag in unrecognized_rule_languages(rule):
out.append(
Finding(
file=_LANGUAGE_SCOPE_FILE,
line=0,
kind=None,
rule=_LANGUAGE_SCOPE_RULE,
level="warn",
message=(
f"custom rule '{rule.id}' scopes to unknown language"
f" '{tag}' — the validator reports {{python, typescript,"
" tsx}}; the rule will never fire"
),
fix_hint="fix the language tag in .roboco/conventions.yml",
)
)
return out
def _check_file(
root: Path, rel: str, language: str, standard: ConventionsStandard
) -> list[Finding]:
+8
View File
@@ -759,6 +759,14 @@ class PlaybookTable(Base):
approved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Distinct from approval: who retired it (archive/reject) and when. Stamping
# the archiver into approved_by overwrote the approval provenance.
archived_by: Mapped[PyUUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class SecretaryDirectiveTable(Base):
+48 -2
View File
@@ -26,6 +26,11 @@ logger = structlog.get_logger()
# Type for event handlers
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
# #19: TTL on the per-(event.id, handler) "already processed" marker. Long
# enough to span a replay window, short enough that the keyspace can't grow
# unbounded across the fleet.
_PROCESSED_KEY_TTL = 3600
class StreamEventBus:
"""
@@ -299,7 +304,16 @@ class StreamEventBus:
return all_succeeded
async def _dispatch_event(self, event: Event) -> bool:
"""Run all handlers for an event; return True if all succeeded."""
"""Run all handlers for an event; return True if all succeeded.
#19: ``recover_pending`` re-delivers a message whose ACK was blocked by a
sibling handler's failure. Without an idempotency guard, a handler that
already SUCCEEDED for that event would re-run on the replay (duplicate
side effects). Each (event.id, handler) is marked processed via SET-NX;
a handler whose key already exists is skipped, and a handler that raised
clears its key so a replay re-runs it. The guard is best-effort no
redis (or a guard error) fails open and just runs the handler.
"""
handlers = self._handlers.get(event.type, [])
if not handlers:
return True
@@ -309,10 +323,42 @@ class StreamEventBus:
event_type=event.type.value,
handler_count=len(handlers),
)
tasks = [handler(event) for handler in handlers]
tasks = [self._run_handler_guarded(event, handler) for handler in handlers]
results = await asyncio.gather(*tasks, return_exceptions=True)
return self._check_handler_results(event, handlers, results)
async def _run_handler_guarded(self, event: Event, handler: EventHandler) -> None:
"""Run one handler under the (event.id, handler) idempotency guard.
Fail-open: with no redis (or a guard error) the handler runs unguarded
rather than being skipped a dedup-infra outage never silently drops
an event.
"""
if self._redis is None:
await handler(event)
return
name = getattr(handler, "__name__", f"h{id(handler)}")
key = f"bus:processed:{event.id}:{name}"
try:
acquired = await self._redis.set(key, "1", nx=True, ex=_PROCESSED_KEY_TTL)
except Exception as e:
logger.warning(
"Bus idempotency guard error; fail-open",
error=e.__class__.__name__,
)
await handler(event)
return
if not acquired:
logger.debug("Skipping already-processed handler on replay", key=key)
return
try:
await handler(event)
except Exception:
# Clear the marker so a replay re-runs this handler — not a phantom success.
with contextlib.suppress(Exception):
await self._redis.delete(key)
raise
async def _handle_message(
self,
stream: str,
+54 -3
View File
@@ -303,14 +303,65 @@ def role_for_slug_or_none(slug: str) -> Role | None:
Returns ``None`` for an unknown/stale slug instead of raising ``KeyError``,
so a stale assignee or notification-target slug can't crash the whole
dispatcher tick. Callers that only need to check "is this a human-only
role?" treat ``None`` as "not human-only" (``None in (CEO, ...)`` is False)
and proceed without raising.
dispatcher tick. Prefer :func:`is_human_only_role` /
:func:`is_spawnable_agent_slug` over re-deriving the human-only set at each
call site a bare ``role in (CEO, ...)`` test treats a stale ``None`` slug
as "not human" and proceeds to spawn it (the #49 layered-guard hole).
"""
row = AGENTS.get(slug)
return row.role if row is not None else None
_HUMAN_ONLY_ROLES: frozenset[Role] = frozenset(
{Role.CEO, Role.PROMPTER, Role.SECRETARY}
)
def is_human_only_role(role: Role | None) -> bool:
"""True for the CEO / prompter / secretary — the human-driven roles.
These are never containers. ``None`` (an unresolvable/stale slug) is False:
a stale slug is not itself
a human role, it is simply unknown. Use :func:`is_spawnable_agent_slug` at
spawn-skip sites where a stale slug must also be skipped.
"""
return role in _HUMAN_ONLY_ROLES
def is_spawnable_agent_slug(slug: str) -> bool:
"""True only when ``slug`` resolves to a known non-human agent role.
A dispatcher may spawn a slug only when this is True. An unresolvable
(stale/renamed) slug returns False so the dispatcher skips instead of
launching a doomed or formerly-human container (#49). Live human-only
roles are False; their dedicated spawn paths do not route through the
dispatcher. Release/reaper paths that *recover* a stale-slug claim should
NOT use this (they want to act on the stale slug, not spawn it).
"""
role = role_for_slug_or_none(slug)
if role is None:
return False
return role not in _HUMAN_ONLY_ROLES
def role_for_uuid_or_none(agent_uuid: object | None) -> Role | None:
"""Resolve a seeded agent's ``UUID`` to its ``Role`` (None if unknown).
The create path carries ``assigned_to`` as a UUID, not a slug, so the
role-vs-task_type guard needs a UUIDrole lookup. Built once over the
seeded ``AGENTS`` table; a non-seeded / stale UUID returns None (the guard
treats None as "not a PM" and proceeds the team-based ``main_pm`` check
still holds the line for the main-pm team).
"""
if agent_uuid is None:
return None
key = str(agent_uuid)
for row in AGENTS.values():
if str(row.uuid) == key:
return row.role
return None
def team_for_slug(slug: str) -> Team:
"""Shorthand for `agent_for_slug(slug).team`."""
return agent_for_slug(slug).team
+29 -2
View File
@@ -15,6 +15,7 @@ between sites. Inputs are typed ``object | None`` because callers pass either OR
from __future__ import annotations
from roboco.foundation.identity import Role
from roboco.models.base import TaskType, Team
@@ -120,6 +121,32 @@ def main_pm_cannot_own_code(*, team: object | None, task_type: object | None) ->
the reassign/escalation diversion, and the claim guard. Accepts either ORM
enum members or their ``.value`` strings (callers pass both shapes).
"""
team_value = str(getattr(team, "value", team))
type_value = str(getattr(task_type, "value", task_type))
team_value = str(getattr(team, "value", team)).lower()
type_value = str(getattr(task_type, "value", task_type)).lower()
return team_value == Team.MAIN_PM.value and type_value == TaskType.CODE.value
def pm_cannot_own_code(
*, role: object | None, task_type: object | None, is_issue_resolution: bool = False
) -> bool:
"""True when a PM role is being handed a ``code`` task it may not own.
Both PM roles (``cell_pm`` and ``main_pm``) *coordinate* planning,
delegation, unblocking they have no verb to write code, so a ``code`` task
assigned/claimed by a PM is the same structural mismatch
``main_pm_cannot_own_code`` guards at creation time. The one deliberate
exception is **issue resolution**: a PM may take a ``code`` task that is in
``needs_revision`` to act on the review/QA issues directly. ``is_issue_resolution``
is the caller's assertion of that state (a claim/reassign of a needs_revision
code task); a freshly created or delegated subtask is never in that state, so
its caller passes ``False``. Non-PM roles and non-code task types always pass.
Accepts ORM enum members or their ``.value`` strings (callers pass both).
"""
role_value = str(getattr(role, "value", role)).lower()
if role_value not in (Role.CELL_PM.value, Role.MAIN_PM.value):
return False
type_value = str(getattr(task_type, "value", task_type)).lower()
if type_value != TaskType.CODE.value:
return False
return not is_issue_resolution
@@ -115,6 +115,19 @@ class ChannelSpec:
# -- Helper sets (DRY across multiple channels) -------------------------------
# Cell-member roles subject to a channel's ``team_scope``. Cross-cell roles
# (MAIN_PM, AUDITOR, CEO, board) are NOT filtered by team_scope. The single
# source of truth — ``agents_config`` and ``seeds/initial_data`` import this
# instead of carrying their own copies (which drifted).
TEAM_SCOPED_ROLES: frozenset[Role] = frozenset(
{
Role.DEVELOPER,
Role.QA,
Role.DOCUMENTER,
Role.CELL_PM,
}
)
# Roles present in every cell channel: cell members + main-pm.
_CELL_READ: frozenset[Role] = frozenset(
{
+103 -2
View File
@@ -350,6 +350,19 @@ _STATUS_TRANSITIONS: tuple[StatusTransition, ...] = (
"ceo_reject",
frozenset({Role.CEO}),
),
# A branchless coordination root (product integration root / MegaTask
# umbrella) has no developer to revise it, so CEO rejection routes it to
# PENDING for the Main PM to re-plan — not NEEDS_REVISION (dev-claim-only,
# would deadlock the root). The runtime applies this via the audited
# privileged override (admin_set_status); the edge is in the spec so future
# admin-override tightening can't wedge the path, and so the transition is
# acknowledged as CEO-legal rather than an unsanctioned out-of-band write.
StatusTransition(
Status.AWAITING_CEO_APPROVAL,
Status.PENDING,
"ceo_reject_to_pool",
frozenset({Role.CEO}),
),
# Direct PM submission for non-dev tasks
StatusTransition(
Status.IN_PROGRESS,
@@ -639,6 +652,16 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
self_review_block=False,
needs_team_match=False,
),
"ceo_reject_to_pool": ActionSpec(
name="ceo_reject_to_pool",
allowed_roles=frozenset({Role.CEO}),
source_statuses=frozenset({Status.AWAITING_CEO_APPROVAL}),
target_status=Status.PENDING,
allowed_task_types=None,
preconditions=(),
self_review_block=False,
needs_team_match=False,
),
"cancel": ActionSpec(
name="cancel",
allowed_roles=frozenset(_PM_ROLES | {Role.CEO}),
@@ -991,6 +1014,72 @@ PRECONDITION_PR_OPEN_STATE = Precondition(
)
# The states from which a dev may re-sync their branch onto its base. A rebase
# is meaningful only while the dev actively works the task and the branch is
# live — once the task is paused / blocked / handed to QA-doc-PM-CEO review /
# terminal, the dev is no longer the actor and a rebase runs against a branch
# whose task is no longer theirs to move. ``sync_branch`` composes=() (no
# composed action supplies a source-status gate), so without this precondition
# the spec gate accepted a COMPLETED/CANCELLED/PAUSED/BLOCKED task and the
# choreographer handler rebased a dead/parked branch (#50).
SYNC_BRANCH_STATES: frozenset[Status] = frozenset(
{
Status.CLAIMED,
Status.IN_PROGRESS,
Status.VERIFYING,
Status.NEEDS_REVISION,
}
)
def _p_sync_branch_state(task: Any, _agent: Any, _ctx: Any) -> bool:
"""True iff the task is in a sync_branch-eligible active dev state."""
status = getattr(task, "status", None)
value = status.value if isinstance(status, Status) else str(status)
return value in {s.value for s in SYNC_BRANCH_STATES}
PRECONDITION_SYNC_BRANCH_STATE = Precondition(
key="sync_branch_state",
check=_p_sync_branch_state,
remediate=(
"sync_branch is only valid while you actively work the task "
"(claimed / in_progress / verifying / needs_revision); a paused, "
"blocked, reviewing, or terminal task's branch is not yours to move"
),
missing_token="sync_branch_state",
rejection_kind="invalid_state",
)
# submit_root's prose asserts "a Main-PM root is planning-typed, never code".
# The creation path (``main_pm_cannot_own_code``) already blocks a code-typed
# Main-PM root at TaskService.create / intake / approve_and_start, so such a
# root is unreachable in production — but the spec gate must back the claim too
# (defense in depth: a future creation-path change can't quietly make submit_root
# accept a code root). Scoped to submit_root only, NOT the shared
# ``submit_for_review`` action (which cell_pm+code submit_up legitimately uses).
def _p_root_not_code(task: Any, _agent: Any, _ctx: Any) -> bool:
tt = getattr(task, "task_type", None)
value = (
tt.value if isinstance(tt, TaskType) else (str(tt) if tt is not None else None)
)
return value != TaskType.CODE.value
PRECONDITION_ROOT_NOT_CODE = Precondition(
key="root_not_code",
check=_p_root_not_code,
remediate=(
"a Main-PM root is planning-typed, never code; the root assembles the "
"cells' merged work — a code-typed root belongs to a developer, not the "
"Main PM. Reassign the code work to a dev and keep the root planning-typed"
),
missing_token="root_not_code",
rejection_kind="invalid_state",
)
_INTENT_VERBS: dict[str, IntentSpec] = {
# Phase 1: developer verbs
"give_me_work": IntentSpec(
@@ -1095,7 +1184,10 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
" by hand, commit, then sync_branch again."
),
composes=(), # git-only verb — no DB transition; the handler runs the git op
extra_preconditions=(PRECONDITION_OWNERSHIP,),
extra_preconditions=(
PRECONDITION_OWNERSHIP,
PRECONDITION_SYNC_BRANCH_STATE,
),
side_effects=(),
next_hint=_next_hint_synced,
),
@@ -1362,7 +1454,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
" task_type-keyed — a Main-PM root is planning-typed, never code."
),
composes=("submit_for_review",),
extra_preconditions=(),
extra_preconditions=(PRECONDITION_ROOT_NOT_CODE,),
# The root→master PR must exist before the reviewer can review it —
# opened here (parent=master, is_root_pr=True), mirroring submit_up's
# pre-create of the cell→root PR.
@@ -1514,6 +1606,15 @@ def _check_claim_rules_narrow(role: Role, task: Any) -> Decision | None:
"""
status = Status(getattr(task, "status", ""))
role_claim_statuses = CLAIM_RULES.get(role, frozenset())
# PM/code invariant is NOT enforced here. A PM's only claim verb is
# i_will_plan, and a cell/main PM planning a code-typed PARENT (to decompose
# + delegate the code) is legitimate (bug-1: scoping pm_cannot_execute_code
# to i_will_plan deadlocked the slice). Execution is already blocked at the
# intent level — i_will_work_on is _DEV_ROLES only, so a PM cannot execute
# code via the execution verb. The create/delegate guards
# (pm_cannot_own_code) block a PM from being ASSIGNED a fresh code task; the
# needs_revision carve-out (a PM resolving review issues directly) is
# naturally allowed because PMs claim NEEDS_REVISION.
if status in role_claim_statuses:
return None
allowed_list = sorted(s.value for s in role_claim_statuses)
+40 -2
View File
@@ -26,6 +26,7 @@ import os
import re
import shutil
import sys
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -38,6 +39,14 @@ if TYPE_CHECKING:
logger = structlog.get_logger(__name__)
# Process-wide serialisation for the single-use refresh-token grant (#94). The
# grok refresh token is invalidated the instant xAI issues the new one, so two
# concurrent ``refresh_if_stale`` calls that both POST the grant would have the
# second use the now-dead old token and burn the credential. The lock +
# re-load-and-recheck inside it makes the loser find the winner's refreshed
# token and return ``fresh`` instead of re-rotating.
_refresh_lock = threading.Lock()
# xAI OIDC issuer; the token endpoint is ``<issuer>/oauth2/token`` (verified via
# the issuer's ``.well-known/openid-configuration``). A per-entry ``oidc_issuer``
# overrides it.
@@ -271,6 +280,30 @@ def _do_refresh(
return "refreshed"
def _recheck_or_refresh(
auth_path: Path,
now: datetime,
skew_seconds: int,
post: Callable[[str, dict[str, str]], dict[str, Any]] | None,
) -> str:
"""Re-load + re-check staleness, then refresh — the locked body of refresh_if_stale.
Run inside ``_refresh_lock`` so a concurrent caller that waited on the lock
re-reads the bundle a single-writer just refreshed and returns ``fresh``
instead of re-POSTing the single-use refresh grant (#94).
"""
bundle = _load(auth_path)
if bundle is None:
return "missing"
entry = _credential_entry(bundle)
if entry is None:
return "no_refresh_token"
entry_key, creds = entry
if not _is_stale(creds, now, skew_seconds):
return "fresh"
return _do_refresh(auth_path, bundle, entry_key, now, post or _post_token)
def refresh_if_stale(
auth_path: Path,
*,
@@ -291,11 +324,16 @@ def refresh_if_stale(
entry = _credential_entry(bundle)
if entry is None:
return "no_refresh_token"
entry_key, creds = entry
_, creds = entry
now = now or datetime.now(UTC)
if not _is_stale(creds, now, skew_seconds):
return "fresh"
return _do_refresh(auth_path, bundle, entry_key, now, post or _post_token)
# Single-use refresh token: hold the lock and re-load + re-check inside it
# so a concurrent caller that waited on the lock finds the refreshed token
# and returns ``fresh`` instead of re-POSTing the grant (which would use the
# already-invalidated old token and burn the credential — #94).
with _refresh_lock:
return _recheck_or_refresh(auth_path, now, skew_seconds, post)
def main(argv: list[str] | None = None) -> int:
+89 -23
View File
@@ -61,23 +61,52 @@ _CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset(
# actually records the attempt. Kinds not in `_CIRCUIT_REJECTION_KINDS` are
# never forwarded (the SDK ignores unknown kinds anyway).
#
# Classification is substring-based so the many custom RobocoError codes
# (A2A_ACCESS_DENIED, NO_WRITE_ACCESS, TASK_NOT_OWNED, …) land on the right
# counted kind without an exhaustive literal map. The NOT_FOUND family returns
# None — parity with the string-error contract that a `not_found` rejection
# does NOT count (retrying a missing resource won't help until state changes).
# The exact map is authoritative for the codes the handlers actually emit, so
# a known code is never misclassified by an accidental substring — e.g.
# AUTHENTICATION_REQUIRED carries no AUTHORIZED/DENIED/PERMISSION substring and
# under a substring-only rule dropped to ``invalid_state`` instead of
# ``not_authorized`` (#161). The NOT_FOUND family maps to None — parity with
# the string-error contract that a `not_found` rejection does NOT count
# (retrying a missing resource won't help until state changes). Unknown codes
# fall through to a substring branch so a new RobocoError code still lands on a
# counted kind without a map update. Mirrors flow_server.
_DICT_ERROR_CODE_MAP: dict[str, str | None] = {
"AUTHENTICATION_REQUIRED": "not_authorized",
"CHANNEL_ACCESS_DENIED": "not_authorized",
"JOURNAL_ACCESS_DENIED": "not_authorized",
"PERMISSION_DENIED": "not_authorized",
"INVALID_INPUT": "incomplete_input",
"VALIDATION_ERROR": "incomplete_input",
"NOT_FOUND": None,
"INVALID_STATE": "invalid_state",
"TASK_LIFECYCLE_ERROR": "invalid_state",
"TASK_OWNERSHIP_ERROR": "invalid_state",
"SERVICE_ERROR": "invalid_state",
"SESSION_CLOSED": "invalid_state",
"FETCH_FAILED": "invalid_state",
"LIST_FAILED": "invalid_state",
"READ_FAILED": "invalid_state",
"SEARCH_FAILED": "invalid_state",
"WRITE_FAILED": "invalid_state",
}
def _classify_dict_error_code(code: str) -> str | None:
upper = code.upper()
if upper in _DICT_ERROR_CODE_MAP:
return _DICT_ERROR_CODE_MAP[upper]
# Unknown code — substring fallback for forward-compat with new codes.
if "NOT_FOUND" in upper:
return None
if (
"DENIED" in upper
or "AUTHORIZED" in upper
or "AUTH" in upper
or "FORBIDDEN" in upper
or "PERMISSION" in upper
):
return "not_authorized"
if upper == "INVALID_INPUT" or "VALIDATION" in upper:
if "VALIDATION" in upper:
return "incomplete_input"
return "invalid_state"
@@ -156,15 +185,15 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
headers=_build_headers(),
json=body,
)
# A 404 here means a manifest-registered content tool has no matching
# route on the orchestrator: every /api/v1/do/* route returns 200 with
# an Envelope (including not_found rejections), so FastAPI's default
# 404 body (no ``error`` field) is always a missing route, never a legit
# Envelope. Synthesize an ``invalid_state`` Envelope so the breaker
# counts it (via ``_classify_rejection``) and the agent gets a
# remediation hint instead of a raw ``detail`` body. A 404 carrying a
# real Envelope (``error`` field) is surfaced as-is. Mirrors
# flow_server._post.
# A 404 here usually means a manifest-registered content tool has no
# matching route on the orchestrator: every /api/v1/do/* route returns
# 200 with an Envelope (including not_found rejections), so FastAPI's
# default 404 body (``{"detail": "Not Found"}``) is a missing route
# synthesize an ``invalid_state`` Envelope so the breaker counts it and
# the agent gets a wiring-gap remediation hint. A 404 carrying a real
# Envelope (``error`` field) is surfaced as-is; a 404 with a
# *descriptive* ``detail`` (not the bare default) is a real resource
# not_found, surfaced as ``not_found`` (#61). Mirrors flow_server._post.
if response.status_code == _MISSING_ROUTE_STATUS:
try:
body_404 = response.json()
@@ -172,6 +201,25 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
body_404 = None
if isinstance(body_404, dict) and "error" in body_404:
payload_404: dict[str, Any] = body_404
elif (
isinstance(body_404, dict)
and isinstance(body_404.get("detail"), str)
and body_404["detail"] != "Not Found"
):
# A real HTTP 404 with a descriptive detail — a resource
# not_found, not a missing route (#61).
verb = _verb_from_path(path)
payload_404 = {
"error": "not_found",
"message": body_404["detail"],
"remediate": (
f"the {verb} call targeted a resource that does not"
f" exist (HTTP 404: {body_404['detail']}). Re-fetch"
f" state and retry on a current id; do not retry the"
f" same id."
),
"missing": [],
}
else:
verb = _verb_from_path(path)
payload_404 = {
@@ -272,7 +320,12 @@ def _record_and_check_circuit(
return payload
if status.get("open") and isinstance(status.get("circuit_envelope"), dict):
circuit_env: dict[str, Any] = status["circuit_envelope"]
# Copy so the SDK's envelope dict is not mutated in place. Nest the
# original fixable rejection as ``inner`` so its kind/message/remediate
# survive the substitution — the circuit_open envelope only says the
# breaker tripped, not WHY the verb failed (#60). Mirrors flow_server.
circuit_env: dict[str, Any] = dict(status["circuit_envelope"])
circuit_env["inner"] = payload
log.info(
"do_server: circuit_open substituted for rejection",
verb=verb,
@@ -764,6 +817,8 @@ def _register_tools() -> list[str]:
missing rather than silently exposing the full do-tool set (which
includes ``commit`` the role-gate would reject it server-side, but
the agent shouldn't see it on its tool palette in the first place).
``ROBOCO_ALLOW_FULL_TOOLSET`` is a dev/test escape hatch that registers
the full tool set instead of raising (#162); default-off.
Returns the list of tool names actually registered.
"""
@@ -772,13 +827,24 @@ def _register_tools() -> list[str]:
manifest_path = os.environ.get(
"ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
)
msg = (
f"do_server: manifest unavailable at {manifest_path};"
f" refusing to register all-tools fallback for role"
f" {AGENT_ROLE!r}. Check the orchestrator manifest mount."
)
log.error("do_server: manifest missing", role=AGENT_ROLE, path=manifest_path)
raise RuntimeError(msg)
if os.environ.get("ROBOCO_ALLOW_FULL_TOOLSET"):
log.warning(
"do_server: manifest missing — ROBOCO_ALLOW_FULL_TOOLSET set,"
" registering the full tool set (dev/test only)",
role=AGENT_ROLE,
path=manifest_path,
)
allowed = list(_TOOLS.keys())
else:
msg = (
f"do_server: manifest unavailable at {manifest_path};"
f" refusing to register all-tools fallback for role"
f" {AGENT_ROLE!r}. Check the orchestrator manifest mount."
)
log.error(
"do_server: manifest missing", role=AGENT_ROLE, path=manifest_path
)
raise RuntimeError(msg)
unknown = [verb for verb in allowed if verb not in _TOOLS]
if unknown:
log.warning(
+14 -1
View File
@@ -60,10 +60,23 @@ async def _handle_write(
"The file has been indexed in RAG and linked to the task."
)
# Surface the repo-commit outcome (#34): a 'failed' commit means the doc
# saved to /app/docs but did NOT reach the project repo — the cell PM must
# be told so the PR does not ship without the docs.
doc_ref = result.get("doc_ref") or {}
commit_status = doc_ref.get("commit_status") if isinstance(doc_ref, dict) else None
if commit_status == "failed":
guidance += (
" WARNING: the doc could NOT be committed to the project repo — tell"
" the cell PM so the docs are not missing from the PR."
)
elif commit_status == "skipped":
guidance += " (saved to /app/docs only — no task branch to commit onto yet)."
return {
"status": status,
"path": path,
"doc_ref": result.get("doc_ref"),
"doc_ref": doc_ref,
"is_update": is_update,
"guidance": guidance,
}
+98 -27
View File
@@ -77,23 +77,52 @@ _CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset(
# actually records the attempt. Kinds not in `_CIRCUIT_REJECTION_KINDS` are
# never forwarded (the SDK ignores unknown kinds anyway).
#
# Classification is substring-based so the many custom RobocoError codes
# (A2A_ACCESS_DENIED, NO_WRITE_ACCESS, TASK_NOT_OWNED, …) land on the right
# counted kind without an exhaustive literal map. The NOT_FOUND family returns
# None — parity with the string-error contract that a `not_found` rejection
# does NOT count (retrying a missing resource won't help until state changes).
# The exact map is authoritative for the codes the handlers actually emit, so
# a known code is never misclassified by an accidental substring — e.g.
# AUTHENTICATION_REQUIRED carries no AUTHORIZED/DENIED/PERMISSION substring and
# under a substring-only rule dropped to ``invalid_state`` instead of
# ``not_authorized`` (#161). The NOT_FOUND family maps to None — parity with
# the string-error contract that a `not_found` rejection does NOT count
# (retrying a missing resource won't help until state changes). Unknown codes
# fall through to a substring branch so a new RobocoError code still lands on a
# counted kind without a map update.
_DICT_ERROR_CODE_MAP: dict[str, str | None] = {
"AUTHENTICATION_REQUIRED": "not_authorized",
"CHANNEL_ACCESS_DENIED": "not_authorized",
"JOURNAL_ACCESS_DENIED": "not_authorized",
"PERMISSION_DENIED": "not_authorized",
"INVALID_INPUT": "incomplete_input",
"VALIDATION_ERROR": "incomplete_input",
"NOT_FOUND": None,
"INVALID_STATE": "invalid_state",
"TASK_LIFECYCLE_ERROR": "invalid_state",
"TASK_OWNERSHIP_ERROR": "invalid_state",
"SERVICE_ERROR": "invalid_state",
"SESSION_CLOSED": "invalid_state",
"FETCH_FAILED": "invalid_state",
"LIST_FAILED": "invalid_state",
"READ_FAILED": "invalid_state",
"SEARCH_FAILED": "invalid_state",
"WRITE_FAILED": "invalid_state",
}
def _classify_dict_error_code(code: str) -> str | None:
upper = code.upper()
if upper in _DICT_ERROR_CODE_MAP:
return _DICT_ERROR_CODE_MAP[upper]
# Unknown code — substring fallback for forward-compat with new codes.
if "NOT_FOUND" in upper:
return None
if (
"DENIED" in upper
or "AUTHORIZED" in upper
or "AUTH" in upper
or "FORBIDDEN" in upper
or "PERMISSION" in upper
):
return "not_authorized"
if upper == "INVALID_INPUT" or "VALIDATION" in upper:
if "VALIDATION" in upper:
return "incomplete_input"
return "invalid_state"
@@ -176,15 +205,18 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
headers=_build_headers(),
json=body,
)
# A 404 here means a manifest-registered verb has no matching route on
# the orchestrator: every gateway route returns 200 with an Envelope
# (including not_found rejections), so FastAPI's default 404 body (no
# ``error`` field) is always a missing route, never a legit Envelope.
# Synthesize an ``invalid_state`` Envelope so the breaker counts it
# (via ``_classify_rejection``) and the agent gets a remediation hint
# instead of a raw ``detail`` body. A 404 carrying a real Envelope (an
# ``error`` field — e.g. a proxy re-status a 200 rejection to 404) is
# surfaced as-is.
# A 404 here usually means a manifest-registered verb has no matching
# route on the orchestrator: every gateway route returns 200 with an
# Envelope (including not_found rejections), so FastAPI's default 404
# body (``{"detail": "Not Found"}``, no ``error`` field) is a missing
# route — synthesize an ``invalid_state`` Envelope so the breaker
# counts it and the agent gets a wiring-gap remediation hint. Two other
# 404 shapes are surfaced more accurately (#61): a 404 carrying a real
# Envelope (an ``error`` field — e.g. a proxy re-status a 200 rejection
# to 404) is surfaced as-is; a 404 carrying a *descriptive* ``detail``
# (not the bare default) is a real resource not_found, not a missing
# route — surface it as ``not_found`` so the agent re-fetches state
# instead of being told the route is unwired.
if response.status_code == _MISSING_ROUTE_STATUS:
try:
body_404 = response.json()
@@ -194,6 +226,25 @@ def _post(path: str, body: dict[str, Any]) -> dict[str, Any]:
# Real Envelope rejection surfaced under a 404 status —
# surface it as-is so the agent sees the real kind/remediate.
payload_404: dict[str, Any] = body_404
elif (
isinstance(body_404, dict)
and isinstance(body_404.get("detail"), str)
and body_404["detail"] != "Not Found"
):
# A real HTTP 404 with a descriptive detail — a resource
# not_found, not a missing route (#61).
verb = _verb_from_path(path)
payload_404 = {
"error": "not_found",
"message": body_404["detail"],
"remediate": (
f"the {verb} call targeted a resource that does not"
f" exist (HTTP 404: {body_404['detail']}). Re-fetch"
f" the task state (give_me_work / resume) and retry on"
f" a current id; do not retry the same id."
),
"missing": [],
}
else:
verb = _verb_from_path(path)
payload_404 = {
@@ -300,7 +351,13 @@ def _record_and_check_circuit(
return payload
if status.get("open") and isinstance(status.get("circuit_envelope"), dict):
circuit_env: dict[str, Any] = status["circuit_envelope"]
# Copy so the SDK's envelope dict is not mutated in place (the SDK may
# reuse it across calls). Nest the original fixable rejection as
# ``inner`` so its kind/message/remediate survive the substitution —
# the circuit_open envelope only says the breaker tripped, not WHY the
# verb failed, and the agent still needs the underlying hint (#60).
circuit_env: dict[str, Any] = dict(status["circuit_envelope"])
circuit_env["inner"] = payload
log.info(
"flow_server: circuit_open substituted for rejection",
verb=verb,
@@ -811,7 +868,10 @@ def _register_tools() -> list[str]:
The manifest is the role-authoritative tool list. Falling back to
all-verbs registration (the previous behaviour) caused PMs to see
developer/QA verbs and call them at wrong URLs (404s). We now refuse
to start without the manifest.
to start without the manifest unless ``ROBOCO_ALLOW_FULL_TOOLSET`` is
set, a dev/test escape hatch that registers the full tool set instead of
raising so the server modules import without a hand-written manifest
(#162). Default-off so production behaviour is unchanged.
Returns the list of verb names actually registered.
"""
@@ -820,16 +880,27 @@ def _register_tools() -> list[str]:
manifest_path = os.environ.get(
"ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json"
)
msg = (
f"flow_server: manifest unavailable at {manifest_path};"
f" refusing to register all-verbs fallback (would let"
f" {AGENT_ROLE!r} call off-role verbs at wrong URLs)."
f" Check that the orchestrator wrote the manifest to its"
f" /app/manifests/ directory and that the agent container"
f" has the bind-mount."
)
log.error("flow_server: manifest missing", role=AGENT_ROLE, path=manifest_path)
raise RuntimeError(msg)
if os.environ.get("ROBOCO_ALLOW_FULL_TOOLSET"):
log.warning(
"flow_server: manifest missing — ROBOCO_ALLOW_FULL_TOOLSET set,"
" registering the full tool set (dev/test only)",
role=AGENT_ROLE,
path=manifest_path,
)
allowed = list(_TOOLS.keys())
else:
msg = (
f"flow_server: manifest unavailable at {manifest_path};"
f" refusing to register all-verbs fallback (would let"
f" {AGENT_ROLE!r} call off-role verbs at wrong URLs)."
f" Check that the orchestrator wrote the manifest to its"
f" /app/manifests/ directory and that the agent container"
f" has the bind-mount."
)
log.error(
"flow_server: manifest missing", role=AGENT_ROLE, path=manifest_path
)
raise RuntimeError(msg)
public = [_INTENT_TO_PUBLIC.get(verb, verb) for verb in allowed]
unknown = [verb for verb in public if verb not in _TOOLS]
if unknown:
+44 -10
View File
@@ -123,6 +123,39 @@ async def propose_draft(draft: dict[str, Any]) -> str:
return f"Could not submit the draft to the panel: {detail}"
def _draft_title(d: dict[str, Any]) -> str | None:
"""A draft is well-formed when it carries a string ``title`` OR ``name``."""
t = d.get("title")
if isinstance(t, str):
return t
n = d.get("name")
if isinstance(n, str):
return n
return None
def _normalize_batch_drafts(
raw: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], int]:
"""Filter + normalize MegaTask drafts for the panel relay (#163).
A draft without a string ``title``/``name`` is dropped and counted. A
``name``-only draft is normalized onto a copy as ``title`` (the caller's
dict is never mutated). Returns ``(well_formed, dropped_count)``.
"""
well_formed: list[dict[str, Any]] = []
for d in raw:
draft_title = _draft_title(d)
if draft_title is None:
continue
if isinstance(d.get("title"), str):
well_formed.append(d)
else:
# name-only — normalize onto a copy so the relay sees a ``title``.
well_formed.append({**d, "title": draft_title})
return well_formed, len(raw) - len(well_formed)
@mcp.tool()
async def propose_batch(drafts: list[dict[str, Any]], title: str = "") -> str:
"""Submit a MegaTask — SEVERAL task drafts at once — for the human to confirm.
@@ -144,25 +177,26 @@ async def propose_batch(drafts: list[dict[str, Any]], title: str = "") -> str:
"No live session id (ROBOCO_PROMPTER_SESSION_ID) — cannot surface the "
"MegaTask."
)
# Drop malformed entries (a draft needs a string title) and refuse to POST an
# empty batch — otherwise it would silently vanish on the panel side, telling
# the agent it succeeded while nothing appears.
raw = drafts or []
well_formed = [
d for d in raw if isinstance(d, dict) and isinstance(d.get("title"), str)
]
# Malformed entries are dropped and counted; an empty batch is refused
# rather than silently vanishing on the panel side (#163).
well_formed, dropped = _normalize_batch_drafts(drafts or [])
if not well_formed:
return (
"That MegaTask had no well-formed task drafts — give each a title and "
"project_id and call propose_batch again."
"That MegaTask had no well-formed task drafts — give each a title"
" (or name) and a project_id and call propose_batch again."
)
payload = {
"drafts": well_formed,
"title": title,
"dropped": len(raw) - len(well_formed),
"dropped": dropped,
}
result = await post_batch(session_id, payload)
if result.get("ok"):
if dropped:
return (
"MegaTask submitted — the human can review it in the panel."
f" {dropped} draft(s) were dropped for missing a title/name."
)
return "MegaTask submitted — the human can review it in the panel."
detail = result.get("detail") or result.get("error") or "unknown error"
return f"Could not submit the MegaTask to the panel: {detail}"
+2
View File
@@ -33,6 +33,8 @@ class Playbook(RobocoBase):
approved_by: UUID | None = None
created_at: datetime | None = None
approved_at: datetime | None = None
archived_by: UUID | None = None
archived_at: datetime | None = None
class PlaybookCreate(RobocoBase):
+8
View File
@@ -58,6 +58,14 @@ class DocRef(RobocoBase):
updated_at: str | None = Field(
default=None, description="ISO timestamp of last update"
)
commit_status: str | None = Field(
default=None,
description=(
"Whether the doc reached the project repo: 'committed', 'skipped' "
"(no task branch — saved to /app/docs only), or 'failed' (git "
"hiccup — tell the cell PM)"
),
)
class ProgressUpdate(RobocoBase):
+375 -56
View File
@@ -46,7 +46,12 @@ from roboco.agents_config import (
)
from roboco.config import settings
from roboco.foundation import identity as _foundation
from roboco.foundation.identity import CELL_TEAMS, Role, role_for_slug_or_none
from roboco.foundation.identity import (
CELL_TEAMS,
is_human_only_role,
is_spawnable_agent_slug,
role_for_slug_or_none,
)
from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _AGENT_LOOP_BUDGET
from roboco.foundation.policy.batch import is_branchless_coordination
from roboco.models import AgentRole, Team
@@ -150,6 +155,11 @@ def _system_api_headers() -> dict[str, str]:
# Consecutive failed recovery probes before the CEO is notified once per episode.
_CEO_NOTIFY_THRESHOLD = 10
# Consecutive strategy-engine cycle failures before the CEO is notified once
# per failure episode (#193). Mirrors _CEO_NOTIFY_THRESHOLD so a persistently
# failing assess() (bad DB / goals row) surfaces instead of silently producing
# nothing every tick.
_STRATEGY_FAIL_CEO_NOTIFY_THRESHOLD = 10
# Persistent-probe-failure escape hatch (F094): if the recovery probe keeps
# failing past this threshold, the probe endpoint itself is the problem (a
# misconfigured URL, a removed API key, a network partition to the probe host)
@@ -359,6 +369,19 @@ class _IntakeRunSpec:
model: str = ""
@dataclass
class _StrategyLoopState:
"""Consecutive-failure tracking for ``_strategy_engine_loop`` (#193).
``failures`` counts consecutive cycle exceptions; ``notified`` gates the
one-CEO-alert-per-episode. Both reset on the first success so a fresh
failure episode re-notifies.
"""
failures: int = 0
notified: bool = False
@dataclass
class _SecretaryRunSpec:
"""Inputs for ``_build_secretary_run_cmd`` (mirrors ``_IntakeRunSpec``).
@@ -382,6 +405,23 @@ class _SecretaryRunSpec:
model: str = ""
# Roles that always work a concrete task — a spawn row with ``task_id IS NULL``
# for one of these is an unattributed-cost bug (the usage rollup can't tie the
# spend to a task). Intake (prompter), secretary, auditor, and PMs legitimately
# spawn taskless, so they are NOT flagged (#11).
_TASKLESS_SPAWN_SUSPECT_ROLES = frozenset({"developer", "qa", "documenter"})
def is_unattributed_delivery_spawn(role: str, task_id: str | None) -> bool:
"""True when a delivery-role spawn carries no ``task_id`` (#11).
The role string comes from ``get_agent_role`` (lowercase); the comparison is
case-insensitive for safety. Used by ``_record_spawn_session`` to warn on
unattributed usage without noise from the intentional taskless roles.
"""
return task_id is None and role.lower() in _TASKLESS_SPAWN_SUSPECT_ROLES
def _read_project_slug(task: dict[str, Any]) -> str | None:
"""Extract project slug from a task payload shape-tolerantly."""
slug = task.get("project_slug")
@@ -843,6 +883,10 @@ class AgentOrchestrator:
# a broken-but-alive agent (see _maybe_recover_broken_gateway).
self._gateway_broken_since: dict[str, datetime] = {}
self._waiting_records: dict[str, WaitingRecord] = {}
# #71: a resumed agent's WaitingRecord is torn down only once liveness is
# confirmed (not on a bare launch) — a container that launches then dies
# immediately would otherwise strand its task until the reaper's TTL.
self._resume_confirm_delay: float = 30.0
self._health_task: asyncio.Task | None = None
self._dispatcher_task: asyncio.Task | None = None
self._sweeper_task: asyncio.Task | None = None
@@ -954,6 +998,10 @@ class AgentOrchestrator:
# killed + evicted so the reaper can release its task; see
# _maybe_kill_wedged_grok.
self._grok_idle_kill_ttl: int = settings.grok_idle_kill_seconds
# #73: a non-GROK agent stuck in a non-verb loop (alive, no heartbeat
# advance) is killed past this longer window so the reaper can release
# its task; see _maybe_kill_stuck_claude.
self._claude_stuck_kill_ttl: int = settings.claude_stuck_kill_seconds
# Cost ceiling (USD) before a live GROK container is killed — the budget
# kill-switch parity (the grok CLI exposes no live usage hook). 0 disables.
# See _enforce_grok_cost_budget.
@@ -1054,6 +1102,35 @@ class AgentOrchestrator:
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*pending, return_exceptions=True)
async def _flush_respawn_tracker(self) -> None:
"""Persist the full in-memory PM-respawn snapshot before the process exits.
Fire-and-forget persists (``_schedule_respawn_persist``) are bounded by
the shutdown drain deadline; one cancelled by that deadline leaves the
durable count lagging the in-memory counter, so the next restart
re-burns the strike threshold against a still-wedged task the exact
re-burn the durable counter exists to stop (#74). Called from ``stop()``
AFTER the bounded drain so it is the last writer (no further gate
mutations fire once the agents and loops are down) and unbounded (a
short upsert must not be dropped on the shutdown path). Best-effort: a
row that fails to persist is logged and skipped, never crashing shutdown
the in-memory value is gone either way once the process exits.
"""
if not self._pm_respawn_tracker:
return
for agent_slug, task_id in list(self._pm_respawn_tracker.keys()):
record = self._pm_respawn_tracker.get((agent_slug, task_id))
if record is None:
continue
try:
await self._persist_respawn_record(agent_slug, task_id, dict(record))
except Exception:
logger.exception(
"shutdown respawn-tracker flush failed for one row; continuing",
agent_id=agent_slug,
task_id=task_id,
)
async def stop(self) -> None:
"""Stop the orchestrator and all agents."""
if getattr(self, "_stopped", False):
@@ -1104,6 +1181,13 @@ class AgentOrchestrator:
# stuck task can't hang shutdown — it is cancelled past the deadline.
await self._drain_bg_tasks()
# #74: flush the authoritative in-memory respawn snapshot AFTER the
# bounded drain so a deadline-cancelled persist can't leave the durable
# count lagging the in-memory counter (and re-burning the strike
# threshold on the next restart). Unbounded — a short upsert must not be
# dropped on the shutdown path.
await self._flush_respawn_tracker()
self._stopped = True
logger.info("Orchestrator stopped")
@@ -2010,17 +2094,23 @@ class AgentOrchestrator:
The container launches with ``-w`` at the worktree; a pruned/evicted
worktree (reaper, disk pressure, manual cleanup while the agent was
down) would start the agent in a missing directory. Idempotent
``ensure_worktree_for_resume`` is a no-op when the worktree is present
and re-adds it (no ``-b``) from the surviving branch ref when pruned.
No-op for branchless / no-task spawns (no worktree).
down) or a vanished clone root (disk loss, a redeploy that wiped
``/data/workspaces``) would start the agent in a missing directory.
Idempotent: a present worktree is a no-op; a pruned worktree is re-added
from the surviving branch ref; a missing clone is re-cloned and the
branch ref recovered from origin (``create_branch`` pushes at claim
time) so the pushed work survives. No-op for branchless / no-task spawns.
A fatal git-state failure (``WorkspaceError`` the branch ref is gone,
so the worktree can't be re-added) releases the claim and aborts the
spawn so the next claim rebuilds the worktree via ``create_branch``
rather than launching the container at a missing ``-w`` path. A
transient failure (DB/other) aborts without releasing the next tick
retries the same claim.
The reaper-style claim release preserves ownership + ``branch_name``, so
a re-dispatch is a RESUME, not a fresh claim ``create_branch`` never
re-runs to re-clone. Without the clone self-heal a vanished clone_root
fatal-looped every tick (``git -C <missing>`` -> release -> re-dispatch
into the same missing clone). A fatal git-state failure
(``WorkspaceError`` the clone won't re-clone, the token is missing,
or the branch ref is unrecoverable) releases the claim and aborts so
the next dispatch retries the rebuild, never launching the container at
a missing ``-w``. A transient failure (DB/other) aborts without
releasing the next tick retries the same claim.
"""
if not (git_context and git_context.task_short_id and git_context.branch_name):
return
@@ -2035,15 +2125,24 @@ class AgentOrchestrator:
try:
async with get_db_context() as db:
await WorkspaceService(db).ensure_worktree_for_resume(
clone_root, worktree, git_context.branch_name
ws = WorkspaceService(db)
# Heal a vanished/unhealthy clone first. The reaper-style claim
# release preserves ownership + branch_name, so a re-dispatch is
# a RESUME, not a fresh claim — create_branch never re-runs to
# re-clone, and ensure_worktree_for_resume would ``git -C`` a
# missing directory and fatal-loop every tick. Skipped on a
# healthy clone (no new fetch overhead on the common resume).
if not WorkspaceService._is_workspace_healthy(clone_root):
await ws.ensure_workspace(project_slug, agent_id)
await ws.ensure_worktree_self_heal(
clone_root, worktree, git_context.branch_name, project_slug
)
except WorkspaceError as e:
# Fatal git state: the branch ref is gone, so the worktree cannot be
# re-added here. Release the claim so the next claim rebuilds the
# worktree via create_branch, and abort before docker run -w lands
# on a missing path. The release is best-effort (suppressed) so a
# release failure never masks the fatal error.
# Fatal git state (clone won't re-clone, token missing, branch ref
# unrecoverable): release the claim so the next dispatch can retry
# the rebuild, and abort before docker run -w lands on a missing
# path. The release is best-effort (suppressed) so a release
# failure never masks the fatal error.
logger.error(
"worktree ensure failed (fatal); releasing claim for rebuild",
agent_id=agent_id,
@@ -2184,7 +2283,7 @@ class AgentOrchestrator:
# through spawn_agent (see the _spawn_intake_container note at the top
# of this file).
_role = role_for_slug_or_none(agent_id)
if _role in (Role.CEO, Role.PROMPTER, Role.SECRETARY):
if is_human_only_role(_role):
logger.error(
"spawn_agent refused for human-only role — dispatchers must never"
" spawn the CEO / prompter / secretary; these are human-driven",
@@ -4823,6 +4922,17 @@ class AgentOrchestrator:
team = get_agent_team(agent_slug) or "backend"
role = get_agent_role(agent_slug) or "developer"
# A delivery-role spawn with no task_id is unattributed usage (#11) —
# the rollup can't tie the spend to a task. Intake/secretary/PM spawns
# legitimately carry no task and are not flagged.
if is_unattributed_delivery_spawn(role, task_id):
logger.warning(
"Spawn session has no task_id for a delivery role — "
"unattributed usage",
agent_slug=agent_slug,
role=role,
)
session_id = _uuid4()
session_factory = get_session_factory()
async with session_factory() as db:
@@ -5621,6 +5731,11 @@ class AgentOrchestrator:
if agent_id not in self._waiting_records:
return None
# #71: a lingering record (a prior resume whose liveness confirmation
# hasn't torn it down yet) must not double-spawn an already-active agent.
if self._is_agent_active(agent_id):
return None
record = self._waiting_records[agent_id]
# Generate resume prompt
@@ -5660,10 +5775,45 @@ class AgentOrchestrator:
# Spawn bailed without launching (provider re-parked). Keep the record
# so the probe-resume loop re-attempts on the next clear.
return instance
if record.waiting_for == "rate_limit_lifted":
# #71: don't tear down the record on a bare launch — a container that
# launches then dies immediately would orphan the task until the
# reaper's TTL. Keep the record past the launch and confirm liveness
# in the background; if the container dies the probe-resume orphan
# fallback re-resumes within a tick instead of waiting the full TTL.
self._schedule_bg(self._confirm_resume_liveness(agent_id))
return instance
del self._waiting_records[agent_id]
await self._delete_waiting_record(agent_id)
return instance
async def _confirm_resume_liveness(self, agent_id: str) -> None:
"""Tear down a resumed agent's WaitingRecord once it is confirmed alive.
A container that launches then dies immediately must not strand its task
until the reaper's TTL: the record is kept past the launch (``resolve_wait``
schedules this) and deleted only once the agent is still active past a
short confirmation window. If the container died, the record survives so
the probe-resume orphan fallback re-resumes on the next tick (#71). The
confirmation reads ``_is_agent_active`` the same signal the spawn gate
trusts so a container the health loop has marked dead keeps its record.
Best-effort: a delete error is swallowed (the in-memory record is gone
either way once the process exits, and the orphan fallback is in-memory).
"""
if agent_id not in self._waiting_records:
return
await asyncio.sleep(self._resume_confirm_delay)
if not self._is_agent_active(agent_id):
return # container died — keep the record for the orphan fallback
del self._waiting_records[agent_id]
try:
await self._delete_waiting_record(agent_id)
except Exception:
logger.warning(
"resume-liveness confirm failed to delete the durable record",
agent_id=agent_id,
)
def _generate_resume_prompt(
self,
record: WaitingRecord,
@@ -6362,23 +6512,67 @@ Start by:
Dormant by default returns immediately unless ``strategy_engine_enabled``
is set, so it adds zero behaviour to a standard deployment. Notify-only;
it never spends or builds.
it never spends or builds. A persistently failing cycle surfaces to the
CEO once per failure episode (#193) instead of silently logging forever.
"""
if not settings.strategy_engine_enabled:
return
from roboco.db import get_db_context
from roboco.services.strategy_engine import get_strategy_engine
state = self._new_strategy_loop_state()
interval = settings.strategy_engine_interval_seconds
while self._running:
try:
await asyncio.sleep(interval)
async with get_db_context() as db:
await get_strategy_engine(db).run_cycle()
await self._strategy_engine_cycle(state)
except asyncio.CancelledError:
break
except Exception:
logger.exception("strategy engine cycle failed")
@staticmethod
def _new_strategy_loop_state() -> _StrategyLoopState:
return _StrategyLoopState()
async def _strategy_engine_cycle(self, state: _StrategyLoopState) -> None:
"""Run one strategy-engine pass; track consecutive failures (#193).
On success the failure state resets (a fresh failure episode later
re-notifies). On a non-cancel failure, count it and notify the CEO once
per episode past ``_STRATEGY_FAIL_CEO_NOTIFY_THRESHOLD``.
"""
from roboco.db import get_db_context
from roboco.services.strategy_engine import get_strategy_engine
try:
async with get_db_context() as db:
await get_strategy_engine(db).run_cycle()
state.failures = 0
state.notified = False
except asyncio.CancelledError:
raise
except Exception:
logger.exception("strategy engine cycle failed")
state.failures += 1
if (
state.failures >= _STRATEGY_FAIL_CEO_NOTIFY_THRESHOLD
and not state.notified
):
state.notified = True
await self._notify_strategy_engine_failure(state.failures)
async def _notify_strategy_engine_failure(self, fail_count: int) -> None:
"""Send one CEO alert that the strategy engine is persistently failing."""
try:
from roboco.services.notification import NotificationService
await NotificationService().send_ack_notification(
from_agent="system",
to_agent="ceo",
body=(
"[strategy engine] persistently failing: the last "
f"{fail_count} cycles raised and produced no "
"observations. Check the orchestrator logs."
),
)
except Exception:
logger.exception("strategy engine failure-notify dropped")
async def _external_pr_poll_loop(self) -> None:
"""Engine 3: discover inbound PRs and open review tasks.
@@ -8544,6 +8738,47 @@ Start now: evidence(task_id="{task_id}")
record = records.get(slug)
return record is not None and record.waiting_for == "rate_limit_lifted"
async def _agent_holds_live_claim(self, slug: str) -> bool | None:
"""Whether ``slug`` currently owns a non-terminal task.
Used by ``_readopt_running_agents`` to tell a still-useful running
container (the agent is mid-task) from a zombie left over after a prior
orchestrator released the claim: registering a zombie ACTIVE would block
the spawn gate from re-dispatching that slug until the stale container is
eventually noticed (#72). Returns True when the slug owns a non-terminal
task, False when it owns nothing (zombie), and None on a lookup error
(indeterminate the caller falls back to today's register behaviour so a
startup DB hiccup can't regress the cold-start double-spawn protection).
"""
from sqlalchemy import select
from roboco.db.base import get_db_context
from roboco.db.tables import TaskTable
from roboco.models.base import TaskStatus
agent_uuid = AGENT_UUIDS.get(slug)
if agent_uuid is None:
return False # unknown slug owns nothing by definition
try:
async with get_db_context() as db:
result = await db.execute(
select(TaskTable.id)
.where(
TaskTable.assigned_to == agent_uuid,
TaskTable.status.notin_(
(TaskStatus.COMPLETED, TaskStatus.CANCELLED)
),
)
.limit(1)
)
return result.first() is not None
except Exception:
logger.warning(
"readopt live-claim lookup failed; falling back to register",
slug=slug,
)
return None
async def _readopt_running_agents(self) -> int:
"""Re-adopt still-running agent containers into ``_instances`` at startup.
@@ -8554,11 +8789,14 @@ Start now: evidence(task_id="{task_id}")
inactive and can double-spawn it onto work its forgotten-but-running
container is already doing. Probe each known agent slug's container (the
same ``docker inspect`` the reaper uses) and register a minimal ACTIVE
instance for any that is running and not already tracked, so both the
reaper's live-skip and the spawn gate see the live agent immediately.
Inert when nothing is running (degrades to today's cold start) and
best-effort: a probe error leaves that slot untracked (the reaper's own
fallback still covers it). Returns the number re-adopted.
instance for any that is running, not already tracked, AND still holds a
live (non-terminal) claim a running container whose claim a prior
orchestrator already released is a zombie and is skipped so it can't
block the spawn gate from re-dispatching that slug (#72). Inert when
nothing is running (degrades to today's cold start) and best-effort: a
probe or claim-lookup error leaves that slot untracked / falls back to
registering (the reaper's own fallback still covers it). Returns the
number re-adopted.
"""
readopted = 0
for slug in AGENT_IMAGES:
@@ -8582,6 +8820,18 @@ Start now: evidence(task_id="{task_id}")
container_id = await self._resolve_container_id(f"roboco-agent-{slug}")
except Exception:
container_id = None
# #72: a running container whose slug no longer holds a live claim is
# a zombie from a prior orchestrator that released the claim — skip it
# so it can't block re-dispatch of the slug. ``None`` (lookup error)
# falls back to registering: a startup DB hiccup must not regress the
# cold-start double-spawn protection this readopt exists to provide.
holds_claim = await self._agent_holds_live_claim(slug)
if holds_claim is False:
logger.info(
"readopt skipped a running zombie container (no live claim)",
slug=slug,
)
continue
self._instances[slug] = AgentInstance(
agent_id=slug,
state=AgentState.ACTIVE,
@@ -8686,6 +8936,70 @@ Start now: evidence(task_id="{task_id}")
)
return True
def _stuck_claude_slug(
self, task: Any, last_heartbeat: "datetime | None"
) -> str | None:
"""Slug of an ACTIVE non-GROK container holding ``task``, stuck past the TTL.
The reaper's live-container skip shields a quiet agent during a long
edit/test cycle correct for a working agent (it fires gateway verbs
every few minutes, advancing its heartbeat). A non-GROK agent stuck in a
non-verb loop is ACTIVE yet silent, so the skip would protect its claim
forever (#73). Returns the slug only for a non-GROK ACTIVE instance whose
heartbeat has been stale longer than ``claude_stuck_kill_seconds`` a
recent heartbeat, no owner, a GROK provider (handled by the wedged-grok
path), or a non-ACTIVE instance all yield ``None``.
"""
from roboco.models.base import ModelProvider
cutoff = datetime.now(UTC) - timedelta(
seconds=getattr(self, "_claude_stuck_kill_ttl", 3600)
)
if last_heartbeat is not None and last_heartbeat >= cutoff:
return None
owner = getattr(task, "assigned_to", None) or getattr(task, "claimed_by", None)
if not owner:
return None
slug = self._resolve_agent_slug(str(owner))
instance = (getattr(self, "_instances", None) or {}).get(slug)
config = getattr(instance, "config", None)
is_active_non_grok = (
instance is not None
and instance.state == AgentState.ACTIVE
and config is not None
and config.provider_type != ModelProvider.GROK.value
)
return slug if is_active_non_grok else None
async def _maybe_kill_stuck_claude(
self, task: Any, last_heartbeat: "datetime | None"
) -> bool:
"""Kill + evict a stuck non-GROK container so the reaper frees its task.
On a kill the container is removed and dropped from ``_instances``.
Returns True only when a container was actually killed; see
:meth:`_stuck_claude_slug` for the eligibility rule (#73).
"""
slug = self._stuck_claude_slug(task, last_heartbeat)
if slug is None:
return False
try:
await self._remove_container(f"roboco-agent-{slug}")
except Exception as exc:
logger.error(
"stuck-claude kill failed; will retry next tick",
agent_id=slug,
error=str(exc),
)
return False
self._instances.pop(slug, None)
logger.warning(
"stuck non-grok container killed and evicted",
agent_id=slug,
task_id=str(getattr(task, "id", "")),
)
return True
async def _maybe_recover_broken_gateway(self, task: Any) -> bool:
"""Kill + evict a live agent whose gateway is broken past the grace window.
@@ -8764,6 +9078,7 @@ Start now: evidence(task_id="{task_id}")
return (
live
and not await self._maybe_kill_wedged_grok(t, ts)
and not await self._maybe_kill_stuck_claude(t, ts)
and not await self._maybe_recover_broken_gateway(t)
)
@@ -10321,14 +10636,12 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
# Human-only roles (CEO / prompter / secretary) are never
# containers — there is no reviewer agent to respawn. Leave
# the task for the human (the CEO approves via the panel).
# Mirrors the spawn_agent human-role guard; a skip here keeps
# a mis-assigned human task from aborting this dispatcher's
# whole tick (the chokepoint would otherwise raise).
if role_for_slug_or_none(assigned_slug) in (
Role.CEO,
Role.PROMPTER,
Role.SECRETARY,
):
# A stale/ex-human slug is also skipped: is_spawnable_agent_slug
# is False for it, so a renamed secretary slug can't slip past
# the layered guard to a doomed spawn (#49). Mirrors the
# spawn_agent human-role guard; a skip here keeps a mis-assigned
# human task from aborting this dispatcher's whole tick.
if not is_spawnable_agent_slug(assigned_slug):
continue
if self._is_agent_active(assigned_slug):
continue
@@ -10499,13 +10812,11 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
# Human-only roles (CEO / prompter / secretary) are never containers —
# there is no agent to respawn. Leave the task as-is for the human to
# act on through the panel; do NOT release it to pending (that would
# re-route a human-owned task to a PM). See spawn_agent's human-role
# guard for the structural backstop.
if role_for_slug_or_none(agent_slug) in (
Role.CEO,
Role.PROMPTER,
Role.SECRETARY,
):
# re-route a human-owned task to a PM). A stale slug (None role) is NOT
# skipped here — a stale-slug claim SHOULD be released to pending so a
# real agent can reclaim it (recovery, not spawning). See spawn_agent's
# human-role guard for the structural backstop.
if is_human_only_role(role_for_slug_or_none(agent_slug)):
return None
# The assignee is running, and on THIS task — healthy.
instance = self._instances.get(agent_slug)
@@ -10924,15 +11235,23 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
# dispatched — the CEO is the human operator and intake/
# secretary are human-driven chats with their own launch
# paths. Spawning a container for one is a trust violation
# (the system acting as the human CEO). The CEO being a
# notification target (board-review handoff, escalation, etc.)
# is expected; it is NOT a spawn signal. Skip — the
# notification stays for the human to read in the panel.
if role_for_slug_or_none(agent_slug) in (
Role.CEO,
Role.PROMPTER,
Role.SECRETARY,
):
# (the system acting as the human CEO). A stale/ex-human slug
# is skipped too (is_spawnable_agent_slug is False for it) so
# a renamed secretary slug can't slip past to a spawn (#49).
# The CEO being a notification target (board-review handoff,
# escalation, etc.) is expected; it is NOT a spawn signal.
# Skip — the notification stays for the human to read. #75:
# surface the skip for a human-only target (vs a silent stale
# slug) so an a2a expecting a human-side action (a CEO sign-off
# relay) is visible in the dispatch log, not silently dropped.
if is_human_only_role(role_for_slug_or_none(agent_slug)):
logger.info(
"a2a request targets a human-only role; left as a "
"notification for the human (not spawned)",
target_slug=agent_slug,
)
continue
if not is_spawnable_agent_slug(agent_slug):
continue
if self._is_agent_active(agent_slug):
+4 -12
View File
@@ -173,18 +173,10 @@ DEFAULT_AGENTS: list[dict[str, Any]] = _build_default_agents()
CEO_AGENT_ID = AGENT_UUIDS["ceo"]
# Cell-member roles subject to a channel's team_scope. Cross-cell roles
# (MAIN_PM, AUDITOR, CEO, board) are NOT filtered by team_scope. Mirrors the
# rule in agents_config._TEAM_SCOPED_ROLES; duplicated here to avoid a
# circular import (agents_config already imports AGENT_UUIDS from this
# module).
_TEAM_SCOPED_ROLES: frozenset[_foundation.Role] = frozenset(
{
_foundation.Role.DEVELOPER,
_foundation.Role.QA,
_foundation.Role.DOCUMENTER,
_foundation.Role.CELL_PM,
}
)
# (MAIN_PM, AUDITOR, CEO, board) are NOT filtered by team_scope. Single-sourced
# in ``foundation.policy.communications.TEAM_SCOPED_ROLES``; aliased here for
# the seed helpers below.
_TEAM_SCOPED_ROLES: frozenset[_foundation.Role] = _comms.TEAM_SCOPED_ROLES
def _slugs_for_role_set(
+19 -6
View File
@@ -9,9 +9,10 @@ project's delivery lifecycle and STOPS. Like self-heal it is conservative:
* **Never self-deploys** it only OPENS a fix task; the fix still ships through
the normal gates (dev -> QA -> PR review -> the CEO's merge). The engine never
starts / approves / merges / deploys.
* **Bounded + deduped per repo** at most one open ci_watch task per repo
(keyed on ``git_url``, so a monorepo's several cell-projects share one fix
task), plus per-cycle and rolling open-task caps.
* **Bounded + deduped per (repo, workflow)** at most one open ci_watch task
per ``(git_url, effective workflow)``: a same-workflow monorepo (several
cell-projects on one repo) shares one fix task, but two RED workflows of one
repo each get their own (#44). Plus per-cycle and rolling open-task caps.
Reuses the hardened per-project CI lookup via ``MultiProjectCITelemetrySource``;
the single-repo self-heal path is untouched.
@@ -136,12 +137,24 @@ class CiWatchEngine(BaseService):
async def _should_open(self, task_svc: TaskService, project: Any) -> bool:
"""True when ``project`` resolves and has no open ci_watch task yet.
Dedupe is per ``git_url`` so a monorepo (several cell-projects, one repo)
gets a single open fix task, not one per cell-project.
Dedupe is per ``(git_url, effective workflow)`` so a monorepo with
several cell-projects on ONE repo still gets a single open fix task per
workflow a same-workflow monorepo collapses to one task, but two RED
workflows of one repo each get their own (#44). The effective workflow is
``ci_watch_workflow`` falling back to the configured default.
"""
if project is None or getattr(project, "id", None) is None:
return False
existing = await task_svc.list_open_ci_watch_tasks(git_url=project.git_url)
workflow = (
str(
getattr(project, "ci_watch_workflow", None)
or settings.ci_watch_default_workflow
).strip()
or None
)
existing = await task_svc.list_open_ci_watch_tasks(
git_url=project.git_url, workflow=workflow
)
return not existing
async def _open_fix_task(
+55 -8
View File
@@ -35,6 +35,7 @@ from roboco.services.base import BaseService
from roboco.services.git import CONVENTIONS_SCAFFOLD_BRANCH, get_git_service
if TYPE_CHECKING:
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable
@@ -61,6 +62,23 @@ class ConventionsHealth:
last_ok_sha: str | None
def _is_unique_violation(exc: IntegrityError) -> bool:
"""Whether ``exc`` is a UNIQUE constraint violation (SQLSTATE 23505).
asyncpg exposes ``sqlstate`` on the wrapped error; psycopg exposes
``pgcode`` / ``sqlstate``. The class-name fallback covers a driver whose
orig lacks a code attribute. Anything else (FK / NOT NULL / check) is a
real bug, not a benign concurrent duplicate (#130).
"""
orig = getattr(exc, "orig", None)
if orig is None:
return False
code = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None)
if code == "23505":
return True
return "UniqueViolation" in type(orig).__name__
class ConventionsService(BaseService):
"""Cache, render, scaffold, and restore a project's conventions standard."""
@@ -71,18 +89,23 @@ class ConventionsService(BaseService):
pid = self._pid(project)
root, head = self._resolve(project, workspace)
cached = await self._cache_get(pid, head)
if cached is not None:
# A cached ``degraded`` row is not trusted: a degraded file may have
# been repaired in place at the same (stale) head key, and serving the
# cached last-good map would hide the repair. Re-derive instead (#132).
if cached is not None and cached.status != "degraded":
return ConventionsStandard.model_validate(cached.effective_map)
file_standard, status = self._read_committed_standard(root)
if status == "degraded":
last_good = await self._latest_ok_map(pid)
if last_good is not None:
await self._cache_put(pid, head, last_good, status)
# Not cached: a degraded row is unstable (the file may be
# repaired in place), so never pin it — re-derive next call.
return last_good
mapping = effective_map(self._derive(root), file_standard)
await self._cache_put(pid, head, mapping, status)
if status != "degraded":
await self._cache_put(pid, head, mapping, status)
return mapping
async def baseline_constraints(
@@ -183,13 +206,23 @@ class ConventionsService(BaseService):
async def health(
self, project: ProjectTable, *, workspace: Path | None = None
) -> ConventionsHealth:
"""Report the standard's status at HEAD + the last-good commit SHA."""
"""Report the standard's status at HEAD + the last-good commit SHA.
The status is the LIVE file state, not a cached row: a cached
``degraded`` can hide an in-place repair at the same (stale) head key
(#132). The map scan is expensive (cached); a single file parse is
cheap (re-read). ``unknown`` is reserved for a project with no
resolvable workspace at all.
"""
pid = self._pid(project)
_root, head = self._resolve(project, workspace)
current = await self._cache_get(pid, head)
root, head = self._resolve(project, workspace)
if root is None:
status = "unknown"
else:
_file_standard, status = self._read_committed_standard(root)
last_ok = await self._latest_ok_row(pid)
return ConventionsHealth(
status=current.status if current is not None else "unknown",
status=status,
head_sha=head,
last_ok_sha=last_ok.commit_sha if last_ok is not None else None,
)
@@ -415,7 +448,21 @@ class ConventionsService(BaseService):
status=status,
)
)
except IntegrityError:
except IntegrityError as exc:
# Only a UNIQUE violation (23505) is the benign concurrent-duplicate
# case the savepoint is for. A FK / NOT NULL / check violation is a
# real bug — silently misattributing it as "concurrent put" would
# hide the failure (#130), so log-error and re-raise instead. The
# savepoint was rolled back (only the failed insert), leaving the
# outer task-create transaction usable.
if not _is_unique_violation(exc):
self.log.error(
"conventions cache insert failed (non-unique integrity error)",
project_id=str(project_id),
commit_sha=commit_sha,
error=str(exc),
)
raise
self.log.debug(
"conventions cache row already present (concurrent put)",
project_id=str(project_id),
+37 -14
View File
@@ -218,8 +218,11 @@ class DocsService(BaseService):
# 4. Search RAG for similar existing documentation (by content, not just title)
existing_path = await self._find_similar_doc(req.title, req.content, team)
if existing_path:
# UPDATE existing doc instead of creating new
if existing_path and Path(existing_path).name == req.filename:
# UPDATE the similar doc — but only when the agent is writing the
# SAME filename. A different filename means the agent intends a new
# file; collapsing it onto the similar doc's path would overwrite
# an unrelated file (#35).
result = await self._update_existing_doc(
agent_id=agent_id,
existing_path=existing_path,
@@ -227,7 +230,8 @@ class DocsService(BaseService):
doc_type=doc_type,
)
else:
# 5. No similar doc found - create new
# 5. No similar doc found (or the similar doc has a different
# filename) — create new.
result = await self._create_new_doc(
agent_id=agent_id,
team=team,
@@ -238,18 +242,26 @@ class DocsService(BaseService):
# 6. Persist the doc into the project's repo and commit it onto the task
# branch, so the documenter's output actually lands in the repository via
# the open PR — not only in the /app/docs knowledge store. Best-effort: a
# documenter without a cloned workspace / task branch still succeeds.
await self._commit_doc_to_repo(agent_id, req, doc_type)
return result
# documenter without a cloned workspace / task branch still succeeds, but
# the outcome is surfaced on the doc_ref (#34) so a failed commit is not
# silently swallowed.
commit_status = await self._commit_doc_to_repo(agent_id, req, doc_type)
rel_path, doc_ref, is_update = result
doc_ref.commit_status = commit_status
return rel_path, doc_ref, is_update
async def _commit_doc_to_repo(
self, agent_id: str, req: WriteDocInput, doc_type: str
) -> None:
) -> str:
"""Write the doc into the project's workspace clone and commit it onto
the task branch, so it persists to the repository through the open PR.
Best-effort: any failure (no task branch yet, workspace not cloned, a git
hiccup) is logged and swallowed it must never fail the doc write.
Best-effort: a failure (no task branch yet, workspace not cloned, a git
hiccup) never fails the doc write, but the outcome is returned so the
caller can surface it (#34) — ``committed`` (landed on the branch),
``skipped`` (no branch / workspace / actor to commit onto the doc
still saved to /app/docs), or ``failed`` (a git hiccup tell the cell
PM the doc did not reach the repo).
"""
from roboco.services.git import get_git_service
from roboco.services.project import get_project_service
@@ -260,15 +272,15 @@ class DocsService(BaseService):
)
task = result.scalar_one_or_none()
if task is None or not task.branch_name or task.project_id is None:
return
return "skipped"
project = await get_project_service(self.session).get(
UUID(str(task.project_id))
)
if project is None:
return
return "skipped"
actor = self._agent_uuid(agent_id)
if actor is None:
return
return "skipped"
git = get_git_service(self.session)
workspace = await git.get_workspace(project.slug, actor)
@@ -292,13 +304,18 @@ class DocsService(BaseService):
task_id=str(req.task_id),
path=rel_path,
)
return "committed"
except Exception as exc:
# Fail-loud (#34): a git hiccup is surfaced as ``failed`` (logged),
# not silently swallowed — the agent can tell the cell PM the doc
# did not reach the repo. The /app/docs write already succeeded.
self.log.warning(
"Could not commit documentation to repo (non-fatal)",
agent_id=agent_id,
task_id=str(req.task_id),
error=str(exc),
)
return "failed"
@staticmethod
def _agent_uuid(agent_id: str) -> UUID | None:
@@ -390,7 +407,10 @@ class DocsService(BaseService):
else:
rel_path = f"{team_path}/{req.filename}"
full_path = DOCS_BASE_PATH / rel_path
# Containment-check the built path (#33): a bare ``DOCS_BASE_PATH /
# rel_path`` would silently escape on a malformed team path. The
# filename is already validated, but the resolved path is asserted too.
full_path = _resolve_contained_path(DOCS_BASE_PATH, rel_path)
self.log.info(
"Creating new documentation",
@@ -428,7 +448,10 @@ class DocsService(BaseService):
doc_type: str,
) -> tuple[str, DocRef, bool]:
"""Update an existing documentation file."""
full_path = DOCS_BASE_PATH / existing_path
# The update path comes from RAG ``source`` — containment-check it
# (#33): a ``source`` that escapes the docs dir must be refused, not
# written / overwritten.
full_path = _resolve_contained_path(DOCS_BASE_PATH, existing_path)
self.log.info(
"Updating existing documentation (RAG dedup)",
+65 -13
View File
@@ -919,6 +919,7 @@ class Choreographer:
agent_id: UUID,
task: Any,
role_str: str | None = None,
skip_dev_guards: bool = False,
) -> Envelope | None:
"""Run concurrency-invariant claim guards. Returns rejection or None.
@@ -934,9 +935,16 @@ class Choreographer:
guard ``unmet_dependency`` still applies to everyone. A ``None`` role
keeps the full guards (safe default for non-PM callers).
``skip_dev_guards`` skips the dev-only guards (``already_active`` /
``paused`` / ``_lane_claim_guard``) for a non-transitioning inspection
claim a pr_reviewer claiming an awaiting_pr_review gate task does not
start work, so the single-active-task / code-lane invariants that gate a
developer starting a code task do not apply (the dependency guard still
runs). See ``claim_gate_review`` (#192).
Pre-gateway location: _helpers.py:124-204.
"""
if role_str not in self._COORDINATOR_ROLES:
if not skip_dev_guards and role_str not in self._COORDINATOR_ROLES:
in_progress = await self.task.list_in_progress_for_agent(agent_id)
if guard := already_active_guard(in_progress, task.id):
return guard
@@ -972,6 +980,8 @@ class Choreographer:
# unless the task is currently claimed/in_progress.
await self.task.release_dependency_blocked_claim(task.id)
return guard
if skip_dev_guards:
return None
return await self._lane_claim_guard(task)
async def _lane_claim_guard(self, task: Any) -> Envelope | None:
@@ -4680,15 +4690,19 @@ class Choreographer:
)
return None
_CELL_PM_SLUGS: ClassVar[frozenset[str]] = frozenset({"be-pm", "fe-pm", "ux-pm"})
@staticmethod
def _validate_assignee_task_type(assigned_to: str, task_type: str) -> str | None:
"""Reject role-vs-type misclassifications.
Rules:
- delegating to a Cell PM requires
``task_type='planning'``. Cell PMs decompose; they don't execute.
- delegating to a PM (Cell PM OR Main PM) requires
``task_type='planning'``. PMs decompose/coordinate; they don't
execute. A freshly delegated subtask is never in ``needs_revision``,
so the issue-resolution carve-out (a PM taking a code task to resolve
review issues) does not apply here that lives at the claim gate
(``lifecycle._check_claim_rules_narrow`` via ``pm_cannot_own_code``).
Main PM was previously omitted (only the cell-PM slug set was
checked), leaving a delegate-to-main-pm-as-code hole.
- (2026-05-11 smoke): delegating to a Developer requires
``task_type in {'code', 'documentation', 'research'}``. Devs
implement. Planning/design/administrative belong to PMs/board.
@@ -4707,13 +4721,11 @@ class Choreographer:
to review PRs of code changes).
- Delegating to a Documenter requires ``task_type='documentation'``.
"""
pm_err = Choreographer._pm_task_type_error(assigned_to, task_type)
if pm_err is not None:
return pm_err
from roboco.foundation.identity import AGENTS, Role, Team
if assigned_to in Choreographer._CELL_PM_SLUGS and task_type != "planning":
return (
f"task_type={task_type!r} is invalid for assignee {assigned_to!r}: "
f"Cell PMs own planning tasks, not code/documentation/etc."
)
agent = AGENTS.get(assigned_to)
if agent is None:
return None
@@ -4736,6 +4748,28 @@ class Choreographer:
)
return None
@staticmethod
def _pm_task_type_error(assigned_to: str, task_type: str) -> str | None:
"""Reject a code/non-planning task_type delegated to a PM (cell or main).
Extracted from ``_validate_assignee_task_type`` so the compound PM guard
doesn't inflate the dispatcher's complexity. A freshly delegated subtask
is never in ``needs_revision``, so the issue-resolution carve-out does
not apply here (it lives at the claim gate).
"""
from roboco.foundation.identity import AGENTS, Role
agent = AGENTS.get(assigned_to)
if agent is None or agent.role not in (Role.CELL_PM, Role.MAIN_PM):
return None
if task_type == "planning":
return None
return (
f"task_type={task_type!r} is invalid for assignee"
f" {assigned_to!r}: PMs own planning tasks, not"
f" code/documentation/etc."
)
@staticmethod
def _developer_task_type_error(
assigned_to: str, is_ux_dev: bool, task_type: str
@@ -4767,13 +4801,19 @@ class Choreographer:
"""
from roboco.foundation.identity import AGENTS, Role, Team
if assigned_to in Choreographer._CELL_PM_SLUGS:
agent = AGENTS.get(assigned_to)
if agent is not None and agent.role is Role.MAIN_PM:
return (
"Main PMs own PLANNING tasks — they coordinate across cells and "
"delegate execution. Pass task_type='planning' when delegating to "
"the Main PM; route code work to a developer via the cell PM."
)
if agent is not None and agent.role is Role.CELL_PM:
return (
"Cell PMs (be-pm/fe-pm/ux-pm) own PLANNING tasks — they "
"decompose the slice and delegate code work to devs. Pass "
"task_type='planning' when delegating to a Cell PM."
)
agent = AGENTS.get(assigned_to)
if agent is not None and agent.role is Role.DEVELOPER:
if agent.team is Team.UX_UI:
return (
@@ -6013,6 +6053,10 @@ class Choreographer:
"already present in the base via a sibling PR that merged "
"first. Completing the task without a redundant merge."
),
# Preserve the superseded branch (audit / reference), matching
# the orchestrator supersede path. close_pull_request defaults
# to non-destructive now; explicit here so the two paths agree.
delete_branch=False,
actor_agent_id=pm_agent_id,
project_id=cast("UUID", t.project_id),
)
@@ -6218,7 +6262,15 @@ class Choreographer:
return None
sha = await self.git.get_pr_head_sha(slug, int(pr_number))
return sha if isinstance(sha, str) else None
except Exception:
except Exception as exc:
# Fail-open (never wedge the PM on a lookup error), but log so a
# regression in the slug resolver / git helper doesn't silently
# turn the pr_fail re-submit loop-stopper into a no-op (#5).
logger.warning(
"unchanged_pr_guard head_sha lookup failed (fail-open)",
pr_number=pr_number,
error=str(exc),
)
return None
async def _submit_up_unchanged_pr_guard(
@@ -112,6 +112,7 @@ class ChoreographerHelpers:
agent_id: UUID,
task: Any,
role_str: str | None = None,
skip_dev_guards: bool = False,
) -> Envelope | None:
raise NotImplementedError
@@ -81,7 +81,11 @@ class PRGateMixin(_Base):
task_id=task_id,
verb="claim_gate_review",
)
guard = await self._run_claim_guards(agent_id=reviewer_agent_id, task=t)
guard = await self._run_claim_guards(
agent_id=reviewer_agent_id,
task=t,
skip_dev_guards=True,
)
if guard:
guard.with_introspection(task=t, role=role_str)
return await self._emit_rejection(
@@ -228,19 +232,55 @@ class PRGateMixin(_Base):
async def _record_gate_verdict_for(
self, verb: str, t: Any, notes: str, *, issues: tuple[str, ...]
) -> None:
) -> str | None:
"""Author the canonical pr_review verdict note before the transition.
On pr_fail also stamp the assembled PR's head SHA so the next submit_root
can structurally refuse to re-submit the unchanged root (the 2026-06-27
infinite pr_fail re-submit loop). Best-effort: a capture failure leaves
head_sha absent and submit_root fails open rather than wedging the PM.
Returns the captured head_sha for pr_fail (None for pr_pass) so the caller
can re-capture after the transition commits and re-stamp if the PR head
advanced in between (#189 staleness).
"""
if verb == "pr_fail":
head_sha = await self._capture_pr_head_sha(t)
self._record_gate_verdict(t, verb, notes, issues=issues, head_sha=head_sha)
else:
self._record_gate_verdict(t, verb, notes, issues=issues)
return head_sha
self._record_gate_verdict(t, verb, notes, issues=issues)
return None
async def _re_stamp_pr_fail_head_sha_if_advanced(
self,
t: Any,
notes: str,
*,
issues: tuple[str, ...],
pre_sha: str | None,
) -> None:
"""Re-capture the PR head SHA after the transition commits and re-stamp
the verdict note when it advanced past the pre-transition capture (#189).
The pre-transition capture can go stale if cell work lands on the root
branch between that capture and the commit; a stale recorded SHA makes
``submit_root`` false-allow an unchanged re-submit and re-opens the
pr_fail loop. Re-stamping only on a real advance keeps the no-advance
case to a single note write. Best-effort: a re-capture failure leaves
the pre-transition SHA in place (the fail-open direction).
"""
try:
post_sha = await self._capture_pr_head_sha(t)
except Exception:
logger.exception(
"pr_fail head-sha re-capture failed (keeping pre-transition sha)",
task_id=str(getattr(t, "id", "")),
)
return
if post_sha is not None and post_sha != pre_sha:
self._record_gate_verdict(
t, "pr_fail", notes, issues=issues, head_sha=post_sha
)
async def _post_gate_review(
self, t: Any, agent: Any, role_str: str, verb: str, notes: str
@@ -319,7 +359,7 @@ class PRGateMixin(_Base):
# Author the canonical pr_review verdict note BEFORE the transition so it
# is persisted by the same commit (mirrors post_pr_review) and stays in
# lock-step with the decision (pr_fail overwrites an earlier pr_pass).
await self._record_gate_verdict_for(verb, t, notes, issues=issues)
pre_sha = await self._record_gate_verdict_for(verb, t, notes, issues=issues)
runner = self._verb_runner()
try:
t = await runner.run_intent(verb, t, agent, spec_ctx)
@@ -356,6 +396,18 @@ class PRGateMixin(_Base):
task_id=task_id,
verb=verb,
)
# pr_fail: re-capture the PR head SHA AFTER the transition commits. The
# pre-transition capture (in _record_gate_verdict_for) can go stale if
# cell work lands on the root branch between that capture and the commit
# — a stale recorded SHA would make submit_root false-allow an unchanged
# re-submit (current head vs an older recorded head ⇒ "different") and
# re-open the pr_fail loop. Re-stamp the note only when the head actually
# advanced, so the no-advance case stays a single note write. Best-effort:
# a re-capture failure leaves the pre-transition SHA in place (fail-open).
if verb == "pr_fail":
await self._re_stamp_pr_fail_head_sha_if_advanced(
t, notes, issues=issues, pre_sha=pre_sha
)
# Post the gate verdict on the PR itself (best-effort, after the DB
# transition — a GitHub failure must not roll back the gate decision).
await self._post_gate_review(t, agent, role_str, verb, notes)
@@ -499,6 +551,36 @@ class PRGateMixin(_Base):
)
return None
@staticmethod
def _gate_review_event_verdict(verb: str, is_root: bool) -> tuple[str, str]:
"""Map the gate verb to a (review event, verdict label) pair.
``pr_pass`` APPROVE, ``pr_fail`` REQUEST_CHANGES except on the
rootmaster PR (``is_root``), which always gets a plain COMMENT so the
gate never leaves an approval that could satisfy branch protection (only
the CEO merges master) nor a blocking review that could impede that merge.
"""
if verb == "pr_pass":
return "COMMENT" if is_root else "APPROVE", "PASSED ✅"
return "COMMENT" if is_root else "REQUEST_CHANGES", "CHANGES REQUESTED 🔴"
@staticmethod
def _gate_review_body(
verdict: str, reviewer_slug: str, notes: str, is_root: bool
) -> str:
"""Render the gate-review comment body posted to the assembled PR."""
body_lines = [
f"## In-path PR-review gate — {verdict}",
"",
f"Reviewed by **{reviewer_slug}** (RoboCo PR reviewer). Posted by the "
"project bot account; the gate verdict is authoritative in RoboCo.",
"",
(notes or "").strip() or "_(no additional notes)_",
]
if is_root:
body_lines += ["", "_Only the CEO merges this PR into `master`._"]
return "\n".join(body_lines)
async def _post_gate_review_to_pr(
self, t: Any, verb: str, reviewer_slug: str, notes: str
) -> None:
@@ -512,31 +594,22 @@ class PRGateMixin(_Base):
For the org's own PRs ``git.post_pr_review`` already downgrades a
forbidden self-review to a COMMENT, so the verdict lands regardless.
"""
slug = await self._project_slug_for(t)
try:
slug = await self._project_slug_for(t)
except Exception:
logger.exception(
"gate review PR post: slug resolve failed",
task_id=str(getattr(t, "id", "")),
)
return
pr_number = getattr(t, "pr_number", None)
if not slug or not pr_number:
return
is_root = getattr(t, "parent_task_id", None) is None
if verb == "pr_pass":
event = "COMMENT" if is_root else "APPROVE"
verdict = "PASSED ✅"
else:
event = "COMMENT" if is_root else "REQUEST_CHANGES"
verdict = "CHANGES REQUESTED 🔴"
body_lines = [
f"## In-path PR-review gate — {verdict}",
"",
f"Reviewed by **{reviewer_slug}** (RoboCo PR reviewer). Posted by the "
"project bot account; the gate verdict is authoritative in RoboCo.",
"",
(notes or "").strip() or "_(no additional notes)_",
]
if is_root:
body_lines += ["", "_Only the CEO merges this PR into `master`._"]
event, verdict = self._gate_review_event_verdict(verb, is_root)
body = self._gate_review_body(verdict, reviewer_slug, notes, is_root)
try:
await self.git.post_pr_review(
slug, int(pr_number), "\n".join(body_lines), event=event
)
await self.git.post_pr_review(slug, int(pr_number), body, event=event)
except Exception:
logger.exception(
"gate review PR post failed", task_id=str(getattr(t, "id", ""))
@@ -170,10 +170,17 @@ class PRReviewerMixin(_Base):
plus the ones a hand-formatter reaches for (``## Summary`` / ``## Issues``
/ ``## Verdict``). A real one-paragraph summary does not contain ``## ``
headers, so the prose word "summary" never trips this.
The header must sit at the START of a line (a real markdown header the
reviewer authored) a header quoted from the PR itself (``> ## Summary``)
or named mid-prose (``the ## Summary section``) is a citation, not a
hand-formatted verdict, and must not trip the guard.
"""
import re
lowered = (body or "").lower()
return any(
header in lowered
re.search(rf"^[ \t]*{re.escape(header)}", lowered, re.MULTILINE)
for header in ("## summary", "## issues", "## verdict", "## findings")
)
+25 -2
View File
@@ -843,8 +843,31 @@ class ContentActions:
# if this transaction rolled back — a divergence agents surface in
# briefings. ``get_db`` commits the session again after the route returns
# (a no-op on the now-clean transaction); this explicit commit is what
# gates the index.
await self.task.session.commit()
# gates the index. A poisoned session (a prior mid-verb failure rolled it
# back -> PendingRollbackError) must NOT 500 the curation verb NOR fall
# through to index an uncommitted playbook: surface a clean invalid_state
# and skip the index. See #55.
from sqlalchemy.exc import PendingRollbackError
try:
await self.task.session.commit()
except PendingRollbackError:
logger.warning(
"playbook curate: gating commit on a rolled-back session",
action=action,
playbook_id=str(playbook_id),
)
return Envelope.invalid_state(
message=(
"the DB session was rolled back by a prior failure; the "
"playbook status change was not committed"
),
remediate=(
"re-fetch the playbook's current status and re-try the "
"curation verb"
),
context_briefing={"playbook_id": str(playbook_id)},
)
if action == "approve":
await svc.index_approved(playbook)
else:
+37 -8
View File
@@ -19,13 +19,14 @@ from roboco.config import settings
# Server-side atomic read-modify-write scripts. Redis single-threads a Lua
# ``EVAL``, so the GET → decode → mutate → SET inside one script is indivisible:
# a concurrent ``activate()`` (a re-park) is serialized entirely before or after
# the script, never interleaved between the script's GET and SET. Without this
# the counter update was a non-atomic ``get_state`` → mutate → ``set`` in Python,
# so a re-park's fresh episode blob (``probe_failures: 0`` + fresh
# ``activated_at`` / ``retry_after`` / ``affected_agents`` / ``kind``) could be
# clobbered by the stale increment writing back the OLD blob — un-resetting the
# counter and overwriting the fresh episode metadata. The scripts mutate ONLY
# a concurrent op is serialized entirely before or after the script, never
# interleaved between the script's GET and SET. Without this the counter update
# was a non-atomic ``get_state`` → mutate → ``set`` in Python, so a re-park's
# fresh episode blob could be clobbered by the stale increment writing back the
# OLD blob. ``activate`` is itself a Lua merge (not a blind SET): it refreshes
# the episode metadata but carries over the previous ``probe_failures`` count,
# so a re-park can no longer wipe an in-flight increment (resetting the give-up
# / CEO-notify count mid-episode). The counter scripts mutate ONLY
# ``probe_failures`` so every other episode field survives the bump.
_INCREMENT_PROBE_FAILURES = """\
-- roboco:increment_probe_failures
@@ -57,6 +58,32 @@ state['probe_failures'] = 0
redis.call('SET', key, cjson.encode(state))
"""
# activate merges: it refreshes the episode metadata (kind / activated_at /
# retry_after / affected_agents) but carries over the previous probe_failures
# count. A blind SET here (the old impl) reset probe_failures to 0, so a
# probe-failure increment that just landed — or was in flight — could be wiped
# by a concurrent re-park, resetting the give-up / CEO-notify count mid-episode.
# Like increment/reset, the read-merge-write runs server-side as one atomic Lua
# EVAL, so it is indivisible w.r.t. the counter scripts.
_ACTIVATE_RATE_LIMIT = """\
-- roboco:activate_rate_limit
local key = KEYS[1]
local fresh = ARGV[1]
local raw = redis.call('GET', key)
if raw then
local prev = cjson.decode(raw)
local old_pf = prev['probe_failures']
if old_pf ~= nil then
local new_state = cjson.decode(fresh)
new_state['probe_failures'] = old_pf
redis.call('SET', key, cjson.encode(new_state))
return old_pf
end
end
redis.call('SET', key, fresh)
return 0
"""
class RateLimitStateTracker:
"""Track rate-limit state for a single AI provider in Redis.
@@ -131,7 +158,9 @@ class RateLimitStateTracker:
"affected_agents": affected_agents or [],
"probe_failures": 0,
}
await r.set(self._key(), json.dumps(state))
# Atomic merge (see _ACTIVATE_RATE_LIMIT): the previous probe_failures
# count is carried over so a re-park cannot wipe an in-flight increment.
await r.eval(_ACTIVATE_RATE_LIMIT, 1, self._key(), json.dumps(state))
async def clear(self) -> None:
"""Remove rate-limit state for this provider."""
+35 -3
View File
@@ -199,6 +199,12 @@ _HTTP_NOT_FOUND = 404
# a concurrent sibling-subtask merge updated the target branch and our local
# refs are stale. `pr_merge` re-syncs and retries exactly once on this code.
_HTTP_CONFLICT = 409
# GitHub returns 405 when the repo's settings disallow the requested merge
# method (e.g. "Squash merges are not allowed on this repository" with the
# squash button off) — distinct from a 405 on an already-merged PR. The agent
# `_merge_with_retry` falls back to a permitted method on this code, mirroring
# the CEO `merge_pull_request` path.
_HTTP_METHOD_NOT_ALLOWED = 405
# --- Self-heal CI signal -------------------------------------------------
# Pull a WINDOW of recent completed runs (not just the single newest) so the
@@ -3553,7 +3559,8 @@ class GitService(BaseService):
target: str
async def _merge_with_retry(self, ctx: GitService._MergeContext) -> Any:
"""Single-retry merge: on 409 (race), sync target then retry once."""
"""Single-retry merge: on 409 (race) sync target then retry; on 405
(repo disallows the merge method) fall back to a permitted method."""
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
@@ -3565,6 +3572,28 @@ class GitService(BaseService):
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if resp.status_code == _HTTP_METHOD_NOT_ALLOWED:
# The repo's settings disallow squash (the button is off). Try a
# method the repo permits before treating this as a conflict —
# mirrors the CEO merge_pull_request 405 fallback so a repo's
# merge-button config can't wedge the PM on an open, mergeable PR.
# A 405 with no permitted fallback (or a second 405) falls through
# to the already-merged disambiguation / MergeConflictError below.
fallback = await self._first_allowed_merge_method(
ctx.owner, ctx.repo, ctx.git_token, exclude="squash"
)
if fallback and fallback != "squash":
self.log.info(
"Merge method refused by repo; retrying with a permitted one",
requested="squash",
fallback=fallback,
owner=ctx.owner,
repo=ctx.repo,
pr=ctx.pr_number,
)
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, fallback
)
if not resp.is_success:
# A merge PUT on an ALREADY-MERGED PR returns the same 405 as a
# genuine "not mergeable" conflict. An already-merged PR (a prior
@@ -3943,14 +3972,17 @@ class GitService(BaseService):
*,
project_id: UUID,
comment: str | None = None,
delete_branch: bool = True,
delete_branch: bool = False,
actor_agent_id: UUID | None = None,
) -> None:
"""Close PR ``pr_number`` on GitHub, optionally with an explanatory comment.
Used to retire a PR whose work is already in the base (superseded) so a
wedged task can complete without a merge the "close the dead PR"
action agents had no verb for. Best-effort branch cleanup on close.
action agents had no verb for. Branch deletion is opt-in
(``delete_branch=False`` by default): a superseded PR's branch may still
be referenced or useful for audit, so close does not destroy it unless
the caller explicitly asks matching the orchestrator supersede path.
``pr_number`` alone is ambiguous across projects (GitHub numbers PRs
per-repo, but ``tasks.pr_number`` stores the bare integer with no repo
+50 -1
View File
@@ -37,6 +37,11 @@ class ProvisionedRepo:
html_url: str
# GitHub's "name already exists on this account" reply on a duplicate repo
# create — the orphaned-repo signal treated idempotently (#83/#84).
_GITHUB_REPO_EXISTS_STATUS = 422
class GitHubProvisioningService:
"""Create private repos in the configured org via the GitHub REST API."""
@@ -76,7 +81,15 @@ class GitHubProvisioningService:
async def create_repo(
self, name: str, description: str = "", *, private: bool = True
) -> ProvisionedRepo:
"""Create ``org/name`` (auto-initialised so it is immediately cloneable)."""
"""Create ``org/name`` (auto-initialised so it is immediately cloneable).
Idempotent by GitHub name: if a prior partially-rolled-back approval left
``org/name`` on GitHub (the DB transaction rolled back but the repo did
not), GitHub replies 422 ``name already exists on this account``. Instead
of erroring and orphaning the re-approval, fetch and return the existing
repo so the caller reuses its ``clone_url`` to (re)register the Project
(#83/#84).
"""
if not self.enabled:
msg = (
"GitHub provisioning is not configured. Set "
@@ -103,6 +116,13 @@ class GitHubProvisioningService:
except httpx.HTTPError as exc:
msg = f"GitHub repo creation failed for '{name}': {exc}"
raise ProvisioningError(msg) from exc
if (
resp.status_code == _GITHUB_REPO_EXISTS_STATUS
and "already exists" in (resp.text or "").lower()
):
# The repo is already on GitHub from a rolled-back prior attempt —
# reuse it instead of orphaning the re-approval.
return await self._fetch_existing_repo(name)
if not resp.is_success:
detail = resp.text[:200] if resp.text else "no body"
msg = (
@@ -117,6 +137,35 @@ class GitHubProvisioningService:
html_url=str(body.get("html_url", "")),
)
async def _fetch_existing_repo(self, name: str) -> ProvisionedRepo:
"""GET ``org/name`` and rebuild a ProvisionedRepo (idempotent re-create)."""
client = await self._http()
try:
resp = await client.get(
f"{self._base_url}/repos/{self._org}/{name}",
headers={
"Authorization": f"Bearer {self._token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=self._timeout,
)
except httpx.HTTPError as exc:
msg = f"GitHub repo fetch failed for '{name}': {exc}"
raise ProvisioningError(msg) from exc
if not resp.is_success:
detail = resp.text[:200] if resp.text else "no body"
msg = (
f"GitHub repo fetch failed for '{name}' ({resp.status_code}): {detail}"
)
raise ProvisioningError(msg)
body = resp.json()
return ProvisionedRepo(
full_name=str(body.get("full_name", f"{self._org}/{name}")),
clone_url=str(body.get("clone_url", "")),
html_url=str(body.get("html_url", "")),
)
def get_github_provisioning_service(
client: httpx.AsyncClient | None = None,
+46 -7
View File
@@ -8,6 +8,7 @@ Supports swimlanes, cross-cell views, and real-time updates.
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import Any, ClassVar
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -43,8 +44,25 @@ class KanbanService(BaseService):
# CARD CREATION
# =========================================================================
async def _load_subtask_counts(self, tasks: Sequence[TaskTable]) -> dict[UUID, int]:
"""#198: batch-count direct children per parent in ONE grouped query, so a
board of N cards doesn't fire N child-count queries (and so the count is
real, not a hardcoded 0)."""
parent_ids = [t.id for t in tasks if t.id is not None]
if not parent_ids:
return {}
result = await self.session.execute(
select(TaskTable.parent_task_id, func.count(TaskTable.id))
.where(TaskTable.parent_task_id.in_(parent_ids))
.group_by(TaskTable.parent_task_id)
)
return {row[0]: int(row[1]) for row in result.all() if row[0] is not None}
async def _task_to_card(
self, task: TaskTable, swimlane_key: str | None = None
self,
task: TaskTable,
swimlane_key: str | None = None,
subtask_counts: dict[UUID, int] | None = None,
) -> KanbanCard:
"""Convert a task to a kanban card."""
# Get assignee name if assigned
@@ -62,8 +80,10 @@ class KanbanService(BaseService):
if last_with_percentage:
progress = last_with_percentage["percentage"]
# Count subtasks (would need a query in real implementation)
subtask_count = 0
# #198: real subtask count from the batch-loaded map (0 when no map / leaf).
subtask_count = (
subtask_counts.get(require_uuid(task.id), 0) if subtask_counts else 0
)
return KanbanCard(
id=require_uuid(task.id),
@@ -143,8 +163,9 @@ class KanbanService(BaseService):
# Add cards to columns
blocked_count = 0
subtask_counts = await self._load_subtask_counts(tasks)
for task in tasks:
card = await self._task_to_card(task)
card = await self._task_to_card(task, subtask_counts=subtask_counts)
# Find the right column for this task's status
for col_id, _, col_status in column_config:
@@ -208,6 +229,7 @@ class KanbanService(BaseService):
lane_key: str,
lane_tasks: list[TaskTable],
column_config: list,
subtask_counts: dict[UUID, int] | None = None,
) -> tuple[list[KanbanColumn], int]:
"""Build columns for a swimlane. Returns (columns, blocked_count)."""
columns: list[KanbanColumn] = []
@@ -215,7 +237,7 @@ class KanbanService(BaseService):
for col_id, col_title, col_status in column_config:
cards = [
await self._task_to_card(t, lane_key)
await self._task_to_card(t, lane_key, subtask_counts=subtask_counts)
for t in lane_tasks
if t.status == col_status
]
@@ -246,6 +268,8 @@ class KanbanService(BaseService):
agent_names = (
await self._fetch_agent_names(tasks) if swimlane_by == "assignee" else {}
)
# #198: load subtask counts once for the whole board (not per lane).
subtask_counts = await self._load_subtask_counts(tasks)
# Group tasks by swimlane key
swimlane_groups: dict[str, list[TaskTable]] = {}
@@ -259,7 +283,10 @@ class KanbanService(BaseService):
for lane_key in sorted(swimlane_groups.keys()):
columns, blocked = await self._build_swimlane_columns(
lane_key, swimlane_groups[lane_key], column_config
lane_key,
swimlane_groups[lane_key],
column_config,
subtask_counts=subtask_counts,
)
total_blocked += blocked
@@ -419,6 +446,12 @@ class KanbanService(BaseService):
KanbanColumn(
id="ux_ui", title="UX/UI", status=TaskStatus.IN_PROGRESS, cards=[]
),
KanbanColumn(
id="coordination",
title="Coordination",
status=TaskStatus.IN_PROGRESS,
cards=[],
),
KanbanColumn(
id="done", title="Done", status=TaskStatus.COMPLETED, cards=[]
),
@@ -427,15 +460,21 @@ class KanbanService(BaseService):
# Sort tasks into columns
col_map = {col.id: col for col in columns}
blocked_count = 0
subtask_counts = await self._load_subtask_counts(tasks)
for task in tasks:
card = await self._task_to_card(task)
card = await self._task_to_card(task, subtask_counts=subtask_counts)
if task.team == Team.BACKEND:
col_map["backend"].cards.append(card)
elif task.team == Team.FRONTEND:
col_map["frontend"].cards.append(card)
elif task.team == Team.UX_UI:
col_map["ux_ui"].cards.append(card)
else:
# Non-cell teams (Main PM, Board, fullstack, system, ...) used
# to be counted in total_cards but never columned — the card was
# built and discarded (#196). Column them under Coordination.
col_map["coordination"].cards.append(card)
if task.status == TaskStatus.BLOCKED:
blocked_count += 1
+21 -1
View File
@@ -34,6 +34,7 @@ from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from roboco.agents_config import get_agent_role
from roboco.config import settings
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
from roboco.models.base import AssignmentScope, ModelProvider
from roboco.models.llm_catalog import (
@@ -84,10 +85,12 @@ async def probe_ollama_tags(base_url: str) -> tuple[list[str], str | None]:
except httpx.HTTPStatusError as exc:
return [], f"Server at {base_url} returned HTTP {exc.response.status_code}"
except Exception as exc:
# Log the exception class only — ``str(exc)`` can carry connection
# internals / stack traces that don't belong in a structured log.
_log.error(
"Unexpected error probing Ollama server",
base_url=base_url,
error=str(exc),
error=exc.__class__.__name__,
)
return [], "An unexpected error occurred while probing the self-hosted server."
@@ -135,6 +138,23 @@ class ModelRoutingService(BaseService):
route = await self._route_from_resolved(resolved, agent_slug)
if route is not None:
return route
elif resolved is not None and not resolved.provider.enabled:
# Configured but disabled — distinguishable from "no assignment"
# so the bypass is surfaced, not silent. Default stays graceful (a
# stalled spawn is worse than a routing miss); ROBOCO_ROUTING_STRICT
# opts into fail-closed for operators who'd rather it stall.
self.log.warning(
"Configured provider is disabled; downgrading to legacy Anthropic path",
agent_slug=agent_slug,
role=role,
provider_id=str(resolved.provider.id),
)
if settings.routing_strict:
raise RuntimeError(
f"routing_strict: agent {agent_slug!r} has a disabled configured "
f"provider {resolved.provider.id}; refusing to silently downgrade "
f"to the legacy Anthropic path"
)
return self._legacy_route(role)
async def _resolve_assignment(
+40 -2
View File
@@ -5,6 +5,7 @@ Collects and aggregates metrics for reporting and dashboards.
Tracks velocity, blockers, completion rates, and agent performance.
"""
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any, ClassVar
from uuid import UUID
@@ -172,6 +173,38 @@ class MetricsService(BaseService):
# BLOCKER METRICS
# =========================================================================
async def _blocked_since_map(
self, blocked_tasks: Sequence[TaskTable]
) -> dict[str, datetime]:
"""Per-task ``blocked since`` timestamp from the ``task.blocked`` audit row.
The real blockage start is the audit transition (#67), indexed on
(target_id, event_type, timestamp); the old ``updated_at`` heuristic
over-counted when a blocked task was later touched for a non-blocking
reason. Returns ``{str(task_id): blocked_at}``; callers fall back to
``updated_at or created_at`` for tasks with no audit row.
"""
blocked_ids = [t.id for t in blocked_tasks]
if not blocked_ids:
return {}
audit_result = await self.session.execute(
select(
AuditLogTable.target_id,
func.max(AuditLogTable.timestamp).label("ts"),
)
.where(
AuditLogTable.event_type == "task.blocked",
AuditLogTable.target_type == "task",
AuditLogTable.target_id.in_(blocked_ids),
)
.group_by(AuditLogTable.target_id)
)
return {
str(row.target_id): row.ts
for row in audit_result.all()
if row.ts is not None
}
async def get_blocker_metrics(self) -> BlockerMetrics:
"""Get metrics about blocked tasks."""
# Count active blockers
@@ -188,6 +221,10 @@ class MetricsService(BaseService):
)
blocked_tasks = blocked_result.scalars().all()
# ``blocked since`` from the ``task.blocked`` audit row, falling back to
# ``updated_at or created_at`` when no audit row exists (#67).
blocked_at = await self._blocked_since_map(blocked_tasks)
# Calculate average blocked time
now = datetime.now(UTC)
blocked_hours = []
@@ -195,8 +232,9 @@ class MetricsService(BaseService):
longest_hours = 0.0
for task in blocked_tasks:
# Assume task got blocked around last update or creation
blocked_since = task.updated_at or task.created_at
blocked_since = (
blocked_at.get(str(task.id)) or task.updated_at or task.created_at
)
hours = (now - blocked_since).total_seconds() / 3600
blocked_hours.append(hours)
+56 -38
View File
@@ -459,6 +459,51 @@ class NotificationService:
)
return to_agents_uuids
async def _duplicate_unacked_exists(
self,
db: AsyncSession,
*,
from_agent_uuid: UUID,
params: CreateNotificationParams,
to_agents_uuids: list[UUID],
) -> bool:
"""True when an unacked same-purpose notification already exists.
Purpose-based dedup (CEO directive, 2026-06-10): same sender, type,
task, overlapping recipients, while a prior one is still unacked
agents re-send the same signal (often reworded) and each copy inflates
the recipient's unacked set, soft-blocking i_am_idle and driving respawn
churn. Body text is NOT compared. Dedup applies only to ACTION-REQUIRED
types; informational carries distinct content per send and acking is
voluntary, so deduping them would silently drop broadcasts.
"""
related = params.related_task_id
if not ACK_REQUIRED_BY_TYPE.get(params.notification_type, True):
return False
dup_q = (
select(NotificationTable.id)
.where(NotificationTable.from_agent == from_agent_uuid)
.where(NotificationTable.type == params.notification_type)
.where(NotificationTable.to_agents.overlap(to_agents_uuids))
.where(~NotificationTable.acked_by.contains(to_agents_uuids))
.where(
NotificationTable.related_task_id == related
if related is not None
else NotificationTable.related_task_id.is_(None)
)
.limit(1)
)
if await db.scalar(dup_q) is not None:
logger.info(
"Suppressed duplicate notification (same purpose, unacked)",
from_agent=str(from_agent_uuid),
type=params.notification_type.value,
related_task_id=str(related) if related is not None else None,
to_agents=[str(a) for a in to_agents_uuids],
)
return True
return False
async def _create_notification(self, params: CreateNotificationParams) -> None:
"""Create a notification via the database and deliver it."""
async with get_db_context() as db:
@@ -503,44 +548,17 @@ class NotificationService:
to_agents=[str(a) for a in to_agents_uuids],
)
return
# Purpose-based dedup (CEO directive, 2026-06-10): do NOT create a
# second notification for the SAME purpose — same sender, same type,
# same task, overlapping recipients — while a prior one is still
# unacknowledged. Agents loop and re-send the same signal (often
# reworded); each copy inflates the recipient's unacked set, which
# soft-blocks their i_am_idle and drives respawn churn. A different
# type, a different task, a different sender, or a recipient who has
# already acked all go through. Body text is NOT compared, so
# rewording cannot defeat the guard.
#
# Dedup only applies to ACTION-REQUIRED types; informational types
# carry distinct content per send and acking is voluntary, so
# deduping them would silently drop broadcasts.
related = params.related_task_id
is_ack_required = ACK_REQUIRED_BY_TYPE.get(params.notification_type, True)
if is_ack_required:
dup_q = (
select(NotificationTable.id)
.where(NotificationTable.from_agent == from_agent_uuid)
.where(NotificationTable.type == params.notification_type)
.where(NotificationTable.to_agents.overlap(to_agents_uuids))
.where(~NotificationTable.acked_by.contains(to_agents_uuids))
.where(
NotificationTable.related_task_id == related
if related is not None
else NotificationTable.related_task_id.is_(None)
)
.limit(1)
)
if await db.scalar(dup_q) is not None:
logger.info(
"Suppressed duplicate notification (same purpose, unacked)",
from_agent=str(from_agent_uuid),
type=params.notification_type.value,
related_task_id=str(related) if related is not None else None,
to_agents=[str(a) for a in to_agents_uuids],
)
return
# Purpose-based dedup (CEO directive, 2026-06-10): suppress a second
# notification for the SAME purpose while a prior one is unacked. See
# ``_duplicate_unacked_exists`` for the rationale + the action-only
# scope (informational types carry distinct content per send).
if await self._duplicate_unacked_exists(
db,
from_agent_uuid=from_agent_uuid,
params=params,
to_agents_uuids=to_agents_uuids,
):
return
notification = NotificationTable(
type=params.notification_type,
priority=params.priority,
+15 -4
View File
@@ -446,11 +446,18 @@ class NotificationDeliveryService(BaseService):
await self.session.flush()
# Publish ACK event
# Defer the ACK event to the session's after_commit (mirror ``deliver``):
# the row state above is only flushed, not durable, so firing the bus
# event now would publish an ACK for an acknowledgement that a rollback
# can still drop. The event is dropped on rollback (no phantom) and fired
# once the row is durable. Best-effort: a bus-init failure is logged but
# never propagates — the ack row state is already flushed and the bus is
# a secondary channel (the row is the durable store).
try:
bus = get_event_bus()
if bus.is_connected():
await bus.publish(
defer_bus_publish(
self.session,
Event(
type=EventType.NOTIFICATION_ACKED,
data={
@@ -458,10 +465,14 @@ class NotificationDeliveryService(BaseService):
"agent_id": str(agent_id),
"ack_type": ack_type,
},
)
),
)
except Exception as e:
self.log.warning("Failed to publish ACK event", error=str(e))
self.log.warning(
"Failed to defer ACK bus publish",
notification_id=str(notification_id),
error=e.__class__.__name__,
)
self.log.info(
"Notification acknowledged",
+7 -7
View File
@@ -1069,13 +1069,13 @@ class OptimalService:
if result is not None and not result.success:
raise RuntimeError(f"Failed to record learning: {result.error}")
# Track in database
import hashlib
content_hash = hashlib.md5(
params.content.encode(), usedforsecurity=False
).hexdigest()[:12]
source = f"roboco://learnings/learn-{content_hash}"
# Track in database. The tracking row's ``source`` MUST match the URI
# the plugin embedded the chunks under (``roboco://learnings/{doc_id}``,
# doc_id = ``lrn-{md5(content[:100])[:12]}``) so a later de-index /
# lookup-by-source against the tracking row finds the chunk rows. The
# plugin already returned that doc_id — reuse it instead of recomputing
# a divergent ``learn-{md5(full_content)}`` that orphans the chunk rows.
source = f"roboco://learnings/{doc_id}"
await self._track_indexed_document(
IndexType.LEARNINGS,
source=source,
@@ -264,6 +264,18 @@ class VectorStore:
for chunk in chunks
if chunk.embedding is not None
]
# #181: an empty ``chunks`` list is a deliberate clear (matches the
# prior delete-then-no-op-add behavior). But chunks passed with NO
# usable embedding is an embedder failure — wiping the source's existing
# rows on a failed embed would lose good index rows for nothing. No-op
# there (distinct from the deliberate empty-list clear below).
if chunks and not records:
logger.warning(
"replace_chunks: every chunk lacked an embedding (embedder "
"failure?); skipping wipe to preserve existing rows",
extra={"source": source, "chunk_count": len(chunks)},
)
return
pool = self._require_pool()
# One acquire, one transaction: the DELETE and INSERT share a single
# connection and commit together (or roll back together on failure).
+6 -2
View File
@@ -9,8 +9,12 @@ lifecycle unchanged.
Partial-failure note: GitHub repo creation is an external side effect that
cannot be rolled back with the DB transaction. If provisioning fails partway,
the DB writes roll back (the route does not commit) but any repos already
created on GitHub remain; re-approval will collide on the repo name. The CEO
resolves such a rare case manually.
created on GitHub remain. Re-approval is idempotent end-to-end: a committed
Project/Product is reused by slug (no re-create), and a GitHub repo left
orphaned by a rolled-back prior attempt is reused by name
``GitHubProvisioningService.create_repo`` treats GitHub's 422 "name already
exists" as "fetch and return the existing repo" instead of erroring (#83/#84).
A re-approval therefore never collides; the CEO need only re-approve.
"""
from __future__ import annotations
+4 -4
View File
@@ -129,8 +129,8 @@ class PlaybookService(BaseService):
resource_type="playbook",
)
playbook.status = PlaybookStatus.ARCHIVED.value
playbook.approved_by = approver_id
playbook.approved_at = datetime.now(UTC)
playbook.archived_by = approver_id
playbook.archived_at = datetime.now(UTC)
await self.session.flush()
self.log.info("Playbook archived", playbook_id=str(playbook_id))
return playbook
@@ -186,8 +186,8 @@ class PlaybookService(BaseService):
resource_type="playbook",
)
playbook.status = PlaybookStatus.ARCHIVED.value
playbook.approved_by = approver_id
playbook.approved_at = datetime.now(UTC)
playbook.archived_by = approver_id
playbook.archived_at = datetime.now(UTC)
await self.session.flush()
self.log.info("Playbook rejected", playbook_id=str(playbook_id), reason=reason)
return playbook
+5 -4
View File
@@ -202,10 +202,11 @@ class ProjectService(BaseService):
self.log.error("Failed to encrypt git token", error=str(e))
raise
# Apply updates for non-None fields (excluding git_token which we handled)
update_data = data.model_dump(
exclude_unset=True, exclude_none=True, exclude={"git_token"}
)
# Apply updates for explicitly-set fields (excluding git_token which we
# handled above). exclude_unset keeps UNSET fields out; we do NOT also
# exclude_none, so a field the caller explicitly set to None clears the
# stored value (distinct from unset = leave unchanged) — #197.
update_data = data.model_dump(exclude_unset=True, exclude={"git_token"})
for key, value in update_data.items():
if hasattr(project, key):
setattr(project, key, value)
+103 -26
View File
@@ -21,7 +21,11 @@ from sqlalchemy import select
from roboco.db.tables import AgentTable, TaskTable
from roboco.foundation.identity import CELL_TEAMS
from roboco.foundation.policy.batch import is_batch_umbrella, main_pm_cannot_own_code
from roboco.foundation.policy.batch import (
is_batch_umbrella,
main_pm_cannot_own_code,
pm_cannot_own_code,
)
from roboco.foundation.policy.content.validators import coerce_str_list
from roboco.foundation.policy.sequencing.models import DraftSurface, SequencePlan
from roboco.models.base import (
@@ -179,13 +183,20 @@ class PrompterService:
team=board until approved (else the CEO's Approve & Start gate, which
keys on team=board, never appears and the task strands). "Approve &
Start" (assignee main-pm) and the post-approval state are team=main_pm.
Product/board routing is consulted BEFORE the multi-cell-map Main-PM
force: a product draft that also carries a 2-cell the_work map is still
a product root, and if it is on the board-review path it must stay
team=board forcing Main PM here would strand the task past the CEO's
Approve & Start gate (#160).
"""
if team_override is not None:
return team_override
if len(_draft_cell_map(draft_data)) >= _MULTI_CELL_MIN:
# Ad-hoc multi-cell map → coordination root, like a product root.
return Team.MAIN_PM
if resolved_product_id is None:
# No product: ad-hoc multi-cell map → coordination root; else the
# single-cell lead cell.
if len(_draft_cell_map(draft_data)) >= _MULTI_CELL_MIN:
return Team.MAIN_PM
return self._lead_cell_team(draft_data, default=default_lead)
if resolved_assigned_to is not None and await self._assignee_is_board(
resolved_assigned_to
@@ -242,6 +253,43 @@ class PrompterService:
return UUID(str(draft_data["assigned_to"]))
return None
def _coerce_pm_code_to_planning(
self,
*,
team: Team,
task_type: TaskType,
resolved_assigned_to: UUID | None,
title: str,
) -> TaskType:
"""Coerce code->planning when the owner is a coordination role (PM).
Two layers: team-based (main_pm) and assignee-based (a PM assignee on a
cell team). A brand-new intake task is never in needs_revision so the
issue-resolution carve-out does not apply.
"""
if main_pm_cannot_own_code(team=team, task_type=task_type):
self.log.info(
"Main-PM intake task coerced code->planning",
team=str(getattr(team, "value", team)),
title=title,
)
return TaskType.PLANNING
if resolved_assigned_to is None:
return task_type
from roboco.foundation.identity import role_for_uuid_or_none
assignee_role = role_for_uuid_or_none(resolved_assigned_to)
if assignee_role is not None and pm_cannot_own_code(
role=assignee_role, task_type=task_type, is_issue_resolution=False
):
self.log.info(
"PM-assignee intake task coerced code->planning",
role=str(getattr(assignee_role, "value", assignee_role)),
title=title,
)
return TaskType.PLANNING
return task_type
async def create_task_from_draft(
self,
draft_data: dict[str, Any],
@@ -255,8 +303,9 @@ class PrompterService:
Recomposes the description, validates exactly-one target, coerces enums,
routes the owning team (product Main PM, project lead cell), and
persists via ``TaskService.create``. Mutates ``draft_data['description']``
in place. ``confirmed_by_human=True`` the CEO confirmed it.
persists via ``TaskService.create``. Operates on a copy the caller's
draft dict is never mutated (#59). ``confirmed_by_human=True`` — the
CEO confirmed it.
``status`` defaults to ``BACKLOG``. The live-intake buttons pass
``PENDING`` + an ``assigned_to`` (a board agent for "Board review &
@@ -272,6 +321,10 @@ class PrompterService:
draft through to the task so the analyzer's surface is persisted.
"""
place = placement or BatchPlacement()
# Coerce + recompose on a copy so the caller's draft is never mutated
# (#59) — the_work unit dicts are copied too, since coercion rewrites
# each unit's ``items``.
draft_data = _copy_draft(draft_data)
self._validate_and_coerce_draft(draft_data)
# Recompose the description from the (possibly edited) structured fields —
# the task always carries a freshly-composed, consistent description.
@@ -290,9 +343,16 @@ class PrompterService:
]
resolved_project_id = None
resolved_product_id = None
elif len(cell_map) == 1:
elif (
len(cell_map) == 1
and resolved_project_id is None
and resolved_product_id is None
):
# A lone the_work cell with no top-level target → single-project
# task on that cell's project. A top-level project_id/product_id
# wins over a redundant 1-cell map — the explicit target is
# preserved, not silently dropped (#57).
resolved_project_id = cell_map[0][1]
resolved_product_id = None
self._validate_draft_target(
resolved_project_id,
resolved_product_id,
@@ -317,22 +377,15 @@ class PrompterService:
default_lead=_lead,
)
# A Main PM coordinates — it never owns a code task. A main_pm + code
# draft is the structural mismatch behind the 2026-06-27 MegaTask
# meltdown (the git/PR/review layer treated the root as code while the
# ownership layer treated it as coordination → pr_fail loop). Intake
# coerces code -> planning here so the combo can never persist; a
# root-subtask / umbrella / single-task main_pm route is a coordination
# root whose code ACs live on the delegated cell/dev leaves. The
# TaskService.create backstop rejects main_pm + code for non-intake
# create paths (the HTTP route).
if main_pm_cannot_own_code(team=team, task_type=task_type):
self.log.info(
"Main-PM intake task coerced code->planning",
team=str(getattr(team, "value", team)),
title=_text(draft_data.get("title")) or "",
)
task_type = TaskType.PLANNING
# A coordination role (PM) never owns a code task — coerce code->planning
# (team-based main_pm path + assignee-based PM path). See
# ``_coerce_pm_code_to_planning`` for the 2026-06-27 meltdown rationale.
task_type = self._coerce_pm_code_to_planning(
team=team,
task_type=task_type,
resolved_assigned_to=resolved_assigned_to,
title=_text(draft_data.get("title")) or "",
)
req = TaskCreateRequest(
title=draft_data["title"],
@@ -872,8 +925,15 @@ def _draft_cell_map(draft: dict[str, Any]) -> list[tuple[Team, UUID]]:
continue
try:
pid = UUID(str(pid_raw))
except (ValueError, TypeError):
continue
except (ValueError, TypeError) as exc:
# A present-but-malformed project_id is a hard error, not an LLM
# cosmetic guess to skip: silently dropping it would collapse a
# 2-cell map to 1-cell and mis-route the draft as a single-project
# task (#58). Reject with a clean 400 so the human re-enters it.
raise ValidationError(
message=f"Invalid project_id in the_work cell '{team_raw}': {pid_raw}",
field="project_id",
) from exc
seen_teams.add(team)
out.append((team, pid))
return out
@@ -893,6 +953,23 @@ def _clean_list(value: Any) -> list[str]:
return coerce_str_list(value)
def _copy_draft(draft: dict[str, Any]) -> dict[str, Any]:
"""A shallow copy of the draft whose ``the_work`` unit dicts are also copied.
Draft coercion (``_validate_and_coerce_draft``) rewrites the top-level list
fields and each the_work unit's ``items``; without copying it would mutate
the caller's draft in place (#59). The unit dicts are copied because their
``items`` is reassigned; nested scalars are read-only.
"""
out = dict(draft)
work = draft.get("the_work")
if isinstance(work, list):
out["the_work"] = [
dict(unit) if isinstance(unit, dict) else unit for unit in work
]
return out
def _text(value: Any) -> str:
"""Trimmed string from a possibly-missing scalar field."""
return str(value or "").strip()
+44 -3
View File
@@ -12,6 +12,7 @@ real git / ``make quality`` / ``gh`` work on a writable clone.
from __future__ import annotations
import asyncio
import contextlib
import re
from dataclasses import dataclass
from pathlib import Path
@@ -96,7 +97,25 @@ class ReleaseExecutor:
detail="make quality failed — aborted before commit (fail-closed).",
)
commit_sha = await self._ops.commit_and_push(version)
try:
commit_sha = await self._ops.commit_and_push(version)
except RuntimeError as exc:
# The ops layer raises RuntimeError on a failed add/commit/push
# (gpgsign/pre-commit reject/no-op bump/non-fast-forward push). That
# is the correct fail-closed abort at the ops layer; the EXECUTOR
# turns it into a structured outcome so the CEO sees the cause
# instead of a 500 bubbling out of ``approve``.
logger.error("release commit/push failed", error=str(exc)[:300])
return ReleaseResult(
status="commit_failed",
version=version,
files_changed=files,
commit_sha=None,
release_url=None,
detail=(
f"release commit/push failed — not published (fail-closed): {exc}"
)[:280],
)
if not await self._ops.wait_for_ci(commit_sha):
return ReleaseResult(
@@ -108,7 +127,24 @@ class ReleaseExecutor:
detail="release-commit CI was not green — not published (fail-closed).",
)
release_url = await self._ops.publish_release(version, report.drafted_changelog)
try:
release_url = await self._ops.publish_release(
version, report.drafted_changelog
)
except RuntimeError as exc:
# ``gh release create`` failed (auth/quota/network). The commit is
# already pushed and CI is green, so the release is half-landed —
# surface it as a structured outcome (not a 500) so the CEO can
# retry ``gh release create`` for the same version.
logger.error("release publish failed", error=str(exc)[:300])
return ReleaseResult(
status="publish_failed",
version=version,
files_changed=files,
commit_sha=commit_sha,
release_url=None,
detail=f"gh release create failed — not published (fail-closed): {exc}",
)
logger.info(
"release published",
version=version,
@@ -155,7 +191,12 @@ async def _await_proc(
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except TimeoutError:
proc.kill()
# Reap the killed child so its PID/PGID and the pipe transport are
# released — a kill without a wait leaves a zombie that wedges the
# release loop's next clone (and leaks FDs over a long release session).
with contextlib.suppress(ProcessLookupError):
proc.kill() # already-exited between the timeout and the kill is fine
await proc.wait()
return _TIMEOUT_RC, f"subprocess timed out after {int(timeout)}s"
return proc.returncode or 0, out.decode("utf-8", "replace")
+122 -22
View File
@@ -11,8 +11,10 @@ executor is fail-closed and the proposal stays open unless a publish succeeds.
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
from uuid import uuid4
import redis.asyncio as redis
@@ -33,10 +35,38 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class ReleaseLockUnavailable(Exception):
"""Redis is unreachable so the release mutex can't be acquired.
Distinct from "the lock is held": a held lock is a concurrent approve (retry
later); an unavailable Redis is an infrastructure failure (fix Redis, then
retry). Both stay fail-closed the execute never runs without the mutex.
"""
# Redis mutex guarding the ~40min release execute against concurrent
# approves; TTL backstops a crash, lock is released on completion.
# approves. The TTL only backstops a crash; a background heartbeat refreshes
# it while the execute owns the lock, and a fencing token makes the release
# compare-and-del so a late first-finally can't delete a usurper's lock.
_RELEASE_LOCK_PREFIX = "roboco:release_proposal:"
_RELEASE_LOCK_TTL_SECONDS = 3000 # 50 min > 40 min CI ceiling
_RELEASE_LOCK_TTL_SECONDS = 3000 # 50 min > 40 min CI ceiling; crash backstop
_RELEASE_LOCK_HEARTBEAT_SECONDS = 60.0
# Only delete/extend the lock when its value still equals our fencing token.
_RELEASE_LOCK_RELEASE_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
_RELEASE_LOCK_HEARTBEAT_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
"""
class ReleaseProposalService(BaseService):
@@ -57,12 +87,16 @@ class ReleaseProposalService(BaseService):
when the release actually publishes a gate/CI failure leaves it open so
the CEO can retry after the cause is fixed.
F013: a Redis ``SET NX`` mutex keyed by the proposal id guards the
~40min execute against concurrent approves (double-click / panel retry)
that would race on the shared, ``rm -rf``'d writable release clone. A
second approve while the lock is held returns ``already_in_progress``
without running the executor. Fail-closed on Redis outage a release is
rare and CEO-gated, and the race it prevents corrupts the release.
A Redis ``SET NX`` mutex keyed by the proposal id guards the execute
against concurrent approves (double-click / panel retry) that would race
on the shared, ``rm -rf``'d writable release clone. The lock value is a
fencing token; a background heartbeat refreshes the TTL while the
execute owns it, and the release is a compare-and-del so a second
approve can't acquire mid-execute (TTL never expires while it's alive)
and a late first-finally can't delete a usurper's lock. A second approve
while the lock is held returns ``already_in_progress`` without running
the executor. Fail-closed on Redis outage a release is rare and
CEO-gated, and the race it prevents corrupts the release.
"""
task = await get_task_service(self.session).get(task_id)
if task is None or task.source != RELEASE_MANAGER_SOURCE:
@@ -72,9 +106,27 @@ class ReleaseProposalService(BaseService):
return None
lock_key = f"{_RELEASE_LOCK_PREFIX}{task_id}"
lock = await self._acquire_release_lock(lock_key)
if lock is None:
report = report_from_dict(report_dict)
report = report_from_dict(report_dict)
try:
lock_token = await self._acquire_release_lock(lock_key)
except ReleaseLockUnavailable as exc:
# Fail-closed: the execute never runs without the mutex. But a Redis
# outage is NOT a concurrent approve — surface the real cause so the
# CEO fixes Redis instead of waiting on a phantom second approve.
logger.error("release lock unavailable (redis down): %s", exc)
return ReleaseResult(
status="redis_unavailable",
version=report.proposed_version,
files_changed=[],
commit_sha=None,
release_url=None,
detail=(
"Redis is unavailable so the release mutex can't be acquired"
" (fail-closed: the execute did not run). Restore Redis and"
" retry — this is not a concurrent-approve conflict."
),
)
if lock_token is None:
return ReleaseResult(
status="already_in_progress",
version=report.proposed_version,
@@ -87,45 +139,93 @@ class ReleaseProposalService(BaseService):
),
)
heartbeat_task: asyncio.Task[None] | None = None
try:
report = report_from_dict(report_dict)
executor = await get_release_executor(self.session)
heartbeat_task = asyncio.create_task(
self._heartbeat_loop(lock_key, lock_token)
)
result = await executor.execute(report)
if result.status == "published":
task.status = TaskStatus.COMPLETED
await self.session.flush()
return result
finally:
await self._release_release_lock(lock_key)
if heartbeat_task is not None:
heartbeat_task.cancel()
await asyncio.gather(heartbeat_task, return_exceptions=True)
await self._release_release_lock(lock_key, lock_token)
async def _acquire_release_lock(self, lock_key: str) -> bool | None:
"""``SET NX EX`` the release mutex. Returns True if acquired, None if held
or Redis is unavailable (fail-closed treat as held)."""
async def _acquire_release_lock(self, lock_key: str) -> str | None:
"""``SET NX EX`` the release mutex with a fencing-token value.
Returns the token if acquired, None if held (a concurrent approve owns
it). Raises :class:`ReleaseLockUnavailable` if Redis itself is
unreachable so the caller can distinguish an infra failure from a
concurrent-approve conflict (both stay fail-closed).
"""
token = uuid4().hex
try:
conn = redis.from_url(settings.redis_url)
try:
acquired = await conn.set(
lock_key, "release", nx=True, ex=_RELEASE_LOCK_TTL_SECONDS
lock_key, token, nx=True, ex=_RELEASE_LOCK_TTL_SECONDS
)
# redis-py returns True on SET NX success, None on conflict.
return True if acquired else None
return token if acquired else None
finally:
await conn.aclose()
except Exception as exc:
logger.warning("release lock acquire failed (redis): %s", exc)
return None
raise ReleaseLockUnavailable(str(exc)) from exc
async def _release_release_lock(self, lock_key: str) -> None:
"""Best-effort ``DEL`` the release mutex (the TTL is the backstop)."""
async def _release_release_lock(self, lock_key: str, token: str) -> None:
"""Compare-and-del the release mutex (only if we still own it)."""
try:
conn = redis.from_url(settings.redis_url)
try:
await conn.delete(lock_key)
await conn.eval(_RELEASE_LOCK_RELEASE_SCRIPT, 1, lock_key, token)
finally:
await conn.aclose()
except Exception as exc:
logger.warning("release lock release failed (redis): %s", exc)
async def _heartbeat_release_lock(self, lock_key: str, token: str) -> bool:
"""Compare-and-expire the release mutex. True if we still own it."""
conn = redis.from_url(settings.redis_url)
try:
res = await conn.eval(
_RELEASE_LOCK_HEARTBEAT_SCRIPT,
1,
lock_key,
token,
_RELEASE_LOCK_TTL_SECONDS,
)
return bool(res)
finally:
await conn.aclose()
async def _heartbeat_loop(self, lock_key: str, token: str) -> None:
"""Refresh the lock TTL while the execute owns it.
Refreshes before the first sleep so a fast execute still extends the
TTL. A refresh error logs and continues (never crashes the execute); if
the lock is no longer ours (returned 0) we stop the TTL backstop and
the fencing token still hold the line.
"""
while True:
try:
if not await self._heartbeat_release_lock(lock_key, token):
logger.critical(
"release lock no longer owned during execute — "
"TTL backstop active; a concurrent approve was refused "
"by the fencing token"
)
return
except Exception as exc:
logger.warning("release lock heartbeat failed (redis): %s", exc)
await asyncio.sleep(_RELEASE_LOCK_HEARTBEAT_SECONDS)
async def reject(self, task_id: UUID, required_changes: str) -> TaskTable | None:
"""Record the CEO's required changes; keep the proposal held for revision."""
task = await get_task_service(self.session).get(task_id)
+9
View File
@@ -441,6 +441,15 @@ def _canonical_bump_files(root: Path, version: str) -> list[str]:
# First release has no prior ``chore(release):`` commit, so derivation
# returns [] — fall back to the version-reference scan: files embedding the
# version are exactly the set a first release must bump. Read-only.
#
# This makes ``_version_ref_gaps`` empty on first release BY DESIGN: the
# bump plan IS every version-bearing file, so the completeness invariant
# (every version-bearing file is in the plan) holds vacuously — there is no
# file that "holds the version but won't be bumped." The CEO still sees the
# set via ``version_bump_plan`` in the report. Re-introducing the old
# ``return []`` would flag EVERY version-bearing file as a gap on first
# release (false alarms). Subsequent releases derive the plan from the last
# release commit, so a NEWLY version-bearing file correctly shows as a gap.
return _tracked_files_with_version(root, version)
+85 -1
View File
@@ -67,6 +67,9 @@ def _fingerprint(signal_name: str) -> str:
return hashlib.sha256(signal_name.encode("utf-8")).hexdigest()[:16]
_NOTIFY_DEDUPE_KEY_PREFIX = "self_heal:notified:"
class SelfHealEngine(BaseService):
"""Detect a regression in RoboCo's own repo; surface it (and later open a fix)."""
@@ -105,24 +108,105 @@ class SelfHealEngine(BaseService):
new regression and STOPS. It never starts, approves, merges, or deploys.
Writes (any opened task) are flushed here; the caller (the orchestrator
loop) owns the commit.
#43: the notify loop dedupes per fingerprint — a regression that stays
red across cycles pings the CEO once per episode, not every tick (the
notification layer's purpose-dedup only holds while unacked; without
this guard a post-ack persistent red state re-fires each cycle). The
CEO alert links the open self-heal fix task when one exists so the panel
can route the CEO to the fix. The dedupe check fails open: a Redis
outage still lets the notify through (better a duplicate ping than a
swallowed regression).
"""
if not settings.self_heal_enabled:
return []
observations = await self.assess()
if not observations:
return []
fp_to_task = await self._open_self_heal_task_ids_by_fp()
notifier = NotificationService()
for obs in observations:
if await self._already_notified(obs.fingerprint):
continue
body = f"[self-heal] {obs.summary}\n\n{obs.detail}"
if obs.raw_ref:
body += f"\n\nEvidence: {obs.raw_ref}"
await notifier.send_ack_notification(
from_agent="system", to_agent="ceo", body=body
from_agent="system",
to_agent="ceo",
body=body,
task_id=fp_to_task.get(obs.fingerprint),
)
await self._mark_notified(obs.fingerprint)
if settings.self_heal_originate_enabled:
await self._originate(observations)
return observations
async def _open_self_heal_task_ids_by_fp(self) -> dict[str, UUID]:
"""Map each open self-heal task's fingerprint to its task id.
Best-effort: a DB/query error returns ``{}`` so the notify loop still
runs (task_id simply stays None the alert floats free, which is the
pre-fix behaviour, never a crash). Used both to link the CEO alert to
the fix task and as a durable "already notified" corroboration.
"""
try:
task_svc = get_task_service(self.session)
open_tasks = await task_svc.list_open_self_heal_tasks()
except Exception:
self.log.exception("self-heal open-task lookup failed; notify floats")
return {}
mapping: dict[str, UUID] = {}
for existing in open_tasks:
fp = extract_self_heal_fingerprint(existing)
if fp and existing.id is not None:
mapping[fp] = cast("UUID", existing.id)
return mapping
async def _already_notified(self, fingerprint: str) -> bool:
"""True if this fingerprint was already CEO-notified this episode.
Fail-open: a Redis error returns False (notify anyway) a dedupe
outage must never swallow a regression alert.
"""
try:
import redis.asyncio # local: redis is an agent-runtime dep
r = redis.asyncio.from_url(settings.redis_url)
try:
return bool(await r.get(self._dedupe_key(fingerprint)))
finally:
await r.aclose()
except Exception:
self.log.exception("self-heal notify-dedupe check failed; failing open")
return False
async def _mark_notified(self, fingerprint: str) -> None:
"""Record that this fingerprint was CEO-notified, with a TTL window.
Best-effort: a Redis error is logged and swallowed the notify already
fired, so a missed mark can at worst cause one duplicate on the next
cycle, never a lost alert.
"""
try:
import redis.asyncio # local: redis is an agent-runtime dep
r = redis.asyncio.from_url(settings.redis_url)
try:
await r.set(
self._dedupe_key(fingerprint),
"1",
ex=settings.self_heal_notify_dedupe_seconds,
)
finally:
await r.aclose()
except Exception:
self.log.exception("self-heal notify-dedupe mark dropped")
@staticmethod
def _dedupe_key(fingerprint: str) -> str:
return f"{_NOTIFY_DEDUPE_KEY_PREFIX}{fingerprint}"
async def _originate(self, observations: list[RegressionObservation]) -> int:
"""Open a PENDING fix task per NEW regression, then STOP. Returns count.
+30 -22
View File
@@ -230,6 +230,32 @@ def _surfaced_siblings(siblings: list) -> list:
]
def _same_assignee_lane_edges(siblings: list) -> list[tuple[object, object]]:
"""Undeclared-surface fallback: chain each (project, assignee) lane by
``(priority, sequence)`` so same-assignee same-repo siblings share a working
tree without an out-of-order merge conflict. Same-assignee scoped so
cross-dev parallel work is untouched; the edge survives reassignment. Same
stable sort re-runs only add edges, never flip a pair into a reverse.
"""
lanes: dict[tuple[str, object], list] = defaultdict(list)
for s in siblings:
proj = getattr(s, "project_id", None)
owner = getattr(s, "assigned_to", None)
if proj is not None and owner is not None:
lanes[(str(proj), owner)].append(s)
fallback: list[tuple[object, object]] = []
for members in lanes.values():
members.sort(
key=lambda s: (
int(getattr(s, "priority", 2)),
int(getattr(s, "sequence", 0)),
)
)
for prev, cur in pairwise(members):
fallback.append((prev.id, cur.id))
return fallback
def dev_task_collision_edges(siblings: list) -> list[tuple[object, object]]:
"""Wire the dev-task collision DAG for a parent's surfaced siblings.
@@ -285,28 +311,10 @@ def dev_task_collision_edges(siblings: list) -> list[tuple[object, object]]:
if edges:
return edges
# Undeclared-surface fallback: same-assignee same-repo siblings share a
# working tree, so chain each (project, assignee) lane by (priority,
# sequence) to avoid an out-of-order merge conflict. Same-assignee scoped so
# cross-dev parallel work is untouched; the edge survives reassignment.
# Only fires with zero collision edges; same stable sort -> re-runs only add.
lanes: dict[tuple[str, object], list] = defaultdict(list)
for s in siblings:
proj = getattr(s, "project_id", None)
owner = getattr(s, "assigned_to", None)
if proj is not None and owner is not None:
lanes[(str(proj), owner)].append(s)
fallback: list[tuple[object, object]] = []
for members in lanes.values():
members.sort(
key=lambda s: (
int(getattr(s, "priority", 2)),
int(getattr(s, "sequence", 0)),
)
)
for prev, cur in pairwise(members):
fallback.append((prev.id, cur.id))
return fallback
# Undeclared-surface fallback (zero collision edges): chain same-assignee
# same-repo lanes so they don't merge out-of-order. See
# ``_same_assignee_lane_edges`` for the rationale + re-run idempotency.
return _same_assignee_lane_edges(siblings)
# ---------------------------------------------------------------------------
+1
View File
@@ -60,6 +60,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("dep_update_enabled", "Dependency-update bot"),
("release_manager_enabled", "Gated release manager"),
("org_memory_enabled", "Organizational memory loop"),
("routing_strict", "Strict model routing (fail-closed on a disabled provider)"),
)
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
+264 -58
View File
@@ -41,6 +41,7 @@ from roboco.foundation.policy.batch import (
is_branchless_coordination,
is_valid_batch_shape,
main_pm_cannot_own_code,
pm_cannot_own_code,
)
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.content.validators import ContentValidationError
@@ -50,6 +51,7 @@ from roboco.models.base import (
BlockerResolverType,
Complexity,
JournalEntryType,
NotificationPriority,
TaskNature,
TaskStatus,
TaskType,
@@ -101,6 +103,31 @@ _ROLE_CLAIM_STATUSES: dict[str, set[TaskStatus]] = {
},
}
# Statuses a task may be escalated FROM into BLOCKED. The strict transition
# validator only allows IN_PROGRESS→BLOCKED, but a chain escalation legitimately
# blocks a task from any active work state (and re-escalates an already-blocked
# task to a new target). BACKLOG is excluded — a never-activated task has no
# business being escalated to BLOCKED (no spec edge, nonsensical). Terminal
# statuses are refused separately by ``_is_terminal_task``. This is the explicit
# escalation exemption to the validator: an enumerated source set, not an
# arbitrary source→BLOCKED write.
_ESCALATABLE_TO_BLOCKED: frozenset[TaskStatus] = frozenset(
{
TaskStatus.PENDING,
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
TaskStatus.VERIFYING,
TaskStatus.AWAITING_QA,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.PAUSED,
TaskStatus.BLOCKED,
}
)
# Board / advisory roles review and advise; they never own or execute a
# descendant code task. Handing one to them (e.g. via the main_pm→product_owner
@@ -540,6 +567,14 @@ class TaskService(BaseService):
service_name: ClassVar[str] = "task"
_background_tasks: ClassVar[set[asyncio.Task[None]]] = set()
# #216: consecutive FS/permission failures during terminal worktree
# cleanup. A systemic FS issue (stuck mount, perms) would otherwise leak
# per-task worktrees indefinitely with only a warning log; after this many
# consecutive OSError failures a CEO alert fires once (suppressed until a
# success resets the streak). Class-level so it spans all cleanup calls.
_worktree_cleanup_fail_streak: ClassVar[int] = 0
_worktree_cleanup_escalated: ClassVar[bool] = False
_WORKTREE_CLEANUP_ESCALATE_AFTER: ClassVar[int] = 3
# =========================================================================
# STATUS TRANSITION HELPER
@@ -698,7 +733,9 @@ class TaskService(BaseService):
# Rework counter: a bounce INTO needs_revision (not a re-entry) is one
# rework cycle. Incremented at this single chokepoint — every transition
# path funnels its audit through here exactly once — so the rework rate
# is an O(1) column read. Synchronous (part of this unit of work).
# is an O(1) column read. Synchronous (part of this unit of work). The
# pre-block RESTORE path undoes this bump when it restores to a
# snapshotted needs_revision (same cycle resuming, not a new rejection).
if (
to_status == TaskStatus.NEEDS_REVISION.value
and from_status != TaskStatus.NEEDS_REVISION.value
@@ -719,7 +756,7 @@ class TaskService(BaseService):
except (ValueError, AttributeError):
agent_uuid = None
details = {
details: dict[str, Any] = {
"from_status": from_status,
"to_status": to_status,
"agent_role": agent_role,
@@ -861,6 +898,39 @@ class TaskService(BaseService):
"(same batch_id, top-level)."
)
def _enforce_no_pm_code_on_create(self, req: TaskCreateRequest) -> None:
"""Refuse a create that would hand a code task to a PM (coordination role).
Two layers: the team-based Main-PM impossibility backstop (a Main PM
coordinates, never owns code the 2026-06-27 MegaTask meltdown shape),
and the assignee-based PM/code guard that closes the create-with-cell-
PM-assignee hole the team check misses. A brand-new task is never in
``needs_revision``, so the issue-resolution carve-out does not apply.
"""
if main_pm_cannot_own_code(team=req.team, task_type=req.task_type):
raise ValidationError(
"MAIN_PM_NO_CODE: A Main PM task coordinates — it does not"
" execute code. Re-draft as `planning` with coordination-level"
" acceptance criteria, or target a cell so a developer owns the"
" code.",
field="task_type",
)
if not req.assigned_to:
return
from roboco.foundation.identity import role_for_uuid_or_none
assignee_role = role_for_uuid_or_none(req.assigned_to)
if assignee_role is not None and pm_cannot_own_code(
role=assignee_role, task_type=req.task_type, is_issue_resolution=False
):
raise ValidationError(
"PM_NO_CODE: the assignee is a PM — PMs coordinate, they"
" do not execute code. Re-draft as `planning` and delegate"
" the code to a developer, or assign the code task to a"
" developer.",
field="task_type",
)
async def create(self, req: TaskCreateRequest) -> TaskTable:
"""
Create a new task.
@@ -874,22 +944,9 @@ class TaskService(BaseService):
if req.parent_task_id:
await self._validate_parent_depth(req.parent_task_id)
# Impossibility backstop: a Main PM coordinates — it never owns a code
# task. ``main_pm`` + ``code`` on the same task is the structural
# mismatch behind the 2026-06-27 MegaTask meltdown (a root-subtask the
# git/PR/review layer treated as code while ownership treated it as
# coordination — never reconciled, pr_fail looped). Intake
# (create_task_from_draft) coerces code→planning, so this fires only on
# a non-intake create (the HTTP route / a direct internal create) that
# tries to persist the forbidden combo.
if main_pm_cannot_own_code(team=req.team, task_type=req.task_type):
raise ValidationError(
"MAIN_PM_NO_CODE: A Main PM task coordinates — it does not"
" execute code. Re-draft as `planning` with coordination-level"
" acceptance criteria, or target a cell so a developer owns the"
" code.",
field="task_type",
)
# PM/code guards: a coordination role never owns a freshly created code
# task (the Main-PM impossibility + the cell-PM-assignee hole).
self._enforce_no_pm_code_on_create(req)
# Stable per-criterion ids (1:1 with acceptance_criteria) so children can
# reference specific parent criteria; generated here when not supplied.
@@ -1186,24 +1243,35 @@ class TaskService(BaseService):
return list(result.scalars().all())
async def list_open_ci_watch_tasks(
self, git_url: str | None = None
self, git_url: str | None = None, workflow: str | None = None
) -> list[TaskTable]:
"""Non-terminal ci_watch fix tasks — the dedupe + open-cap basis.
Optionally scoped to one repo by ``git_url``: a monorepo registers
several cell-projects on ONE git_url, so CI-watch dedupe must key on the
repo, not the project slug otherwise a red monorepo would open one fix
task per cell-project. While an open task exists for a repo the loop must
not originate a second; the rolling open-task cap counts these.
task per cell-project. Optionally further scoped by ``workflow``: the
dedupe key is ``(git_url, effective workflow)`` so a multi-workflow
monorepo with two RED workflows gets a fix task per workflow, not one
collapsed task that silently leaves the second workflow un-remediated
(#44). The effective workflow is ``COALESCE(ci_watch_workflow, default)``
so a NULL-workflow project row matches the default workflow.
"""
stmt = select(TaskTable).where(
TaskTable.source == CI_WATCH_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
if git_url is not None:
stmt = stmt.join(
ProjectTable, TaskTable.project_id == ProjectTable.id
).where(ProjectTable.git_url == git_url)
if git_url is not None or workflow is not None:
stmt = stmt.join(ProjectTable, TaskTable.project_id == ProjectTable.id)
if git_url is not None:
stmt = stmt.where(ProjectTable.git_url == git_url)
if workflow is not None:
from roboco.config import settings
effective = func.coalesce(
ProjectTable.ci_watch_workflow, settings.ci_watch_default_workflow
)
stmt = stmt.where(effective == workflow)
result = await self.session.execute(stmt)
return list(result.scalars().all())
@@ -2064,6 +2132,7 @@ class TaskService(BaseService):
*,
actor_id: str | UUID | None = None,
actor_role: str | None = None,
force: bool = False,
) -> TaskTable | None:
"""Privileged override: set a task's status directly, always audited.
@@ -2072,6 +2141,11 @@ class TaskService(BaseService):
task whose work already merged out-of-band). The change is recorded in
the audit log like any other transition no status change may skip it.
#13: ``force`` marks the override as an explicit, acknowledged bypass
(the route requires it for terminal/final hatch states). It is stamped
into the audit row's ``details`` so a forced override is distinguishable
from an in-band transition in the audit journey.
Taking a task OUT of ``blocked`` here (operator PATCH, or the
orchestrator's auto-recover/auto-resume) restores the pre-block owner
exactly as ``unblock(restore=True)`` does. Without this, a code task
@@ -2104,12 +2178,40 @@ class TaskService(BaseService):
agent_role=actor_role,
audit_agent_id=actor_id,
)
if force:
# #13: a distinct audit row marks the override as an explicit,
# acknowledged bypass past the lifecycle gate — distinguishable
# from the in-band transition row above in the audit journey.
from roboco.db.tables import AuditLogTable
agent_uuid_force: UUID | None = None
if actor_id is not None:
try:
agent_uuid_force = UUID(str(actor_id))
except (ValueError, AttributeError):
agent_uuid_force = None
self.session.add(
AuditLogTable(
event_type="task.admin_override",
agent_id=agent_uuid_force,
target_type="task",
target_id=task.id,
severity="warning",
details={
"from_status": from_status,
"to_status": new_status.value,
"agent_role": actor_role,
"forced": True,
},
)
)
self.log.info(
"Task status set via admin override",
task_id=str(task_id),
from_status=from_status,
to_status=new_status.value,
actor=str(actor_id) if actor_id else None,
forced=force,
)
return task
@@ -4939,6 +5041,24 @@ class TaskService(BaseService):
await self._unblock_dependents(task_id)
return task
async def _escalation_diverts_to_pool(
self, task: TaskTable, target_agent_id: UUID
) -> bool:
"""True when an escalation target cannot own the task — divert to the pool.
Two shapes: a board/advisory role assigned work it has no verb for
(``_board_cannot_own``), and a Main-PM target handed a main_pm+code task
(the 2026-06-27 meltdown shape a coordinator with no code verb cannot
fix the code). Either diverts to a pool release for a role-matched reclaim.
"""
if _board_cannot_own(task) and await self._is_board_advisory_agent(
target_agent_id
):
return True
return main_pm_cannot_own_code(
team=task.team, task_type=task.task_type
) and await self._is_main_pm_agent(target_agent_id)
async def apply_escalation(
self,
*,
@@ -4987,27 +5107,27 @@ class TaskService(BaseService):
target=target_slug,
)
return False
if _board_cannot_own(task) and await self._is_board_advisory_agent(
target_agent_id
):
await self._release_code_task_to_pool(
task=task,
escalator_slug=escalator_slug,
blocked_target_slug=target_slug,
reason=reason,
current = (
task.status
if isinstance(task.status, TaskStatus)
else TaskStatus(str(task.status))
)
if current not in _ESCALATABLE_TO_BLOCKED:
# No arbitrary source→BLOCKED write: a status outside the escalation
# exemption (e.g. BACKLOG, never activated) has no spec edge into
# BLOCKED. Refuse rather than bypass the validator silently.
self.log.warning(
"Refusing escalation from non-escalatable status",
task_id=str(task.id),
status=str(task.status),
escalator=escalator_slug,
target=target_slug,
)
return True
# Impossibility backstop: a Main-PM target must never receive (back) a
# main_pm + code task — a coordinator with no code verb cannot fix the
# code, so escalating it to Main PM perpetuates the mismatch (the
# 2026-06-27 meltdown shape). Scoped to the team+type combo (NOT a broad
# code+main-pm-target rule) so a legacy main_pm+code task can still be
# escalated to a cell dev — the correct remediation. The combo is
# uncreatable going forward (create backstop + intake coercion), so this
# is a backstop for legacy / direct-ORM-write tasks.
if main_pm_cannot_own_code(
team=task.team, task_type=task.task_type
) and await self._is_main_pm_agent(target_agent_id):
return False
if await self._escalation_diverts_to_pool(task, target_agent_id):
# A board/advisory or Main-PM-coordination target cannot own this
# task — divert to a pool release so a role-matched agent reclaims
# it (see ``_escalation_diverts_to_pool`` for the two shapes).
await self._release_code_task_to_pool(
task=task,
escalator_slug=escalator_slug,
@@ -5198,13 +5318,14 @@ class TaskService(BaseService):
task.completed_at = datetime.now(UTC)
# Validate transition with CEO role requirement
self._validate_and_set_status(task, TaskStatus.COMPLETED, "ceo")
await self.session.flush()
# Mirror ``complete()``: close the work session (the PR is merged) and
# run the full completion-hook fan-out (commits + dev_notes indexing),
# not a lone learnings task. Without this the work session stayed ACTIVE
# forever after a CEO approval and code/decision RAG indexing was skipped.
await self._close_work_session_for_task(task, reason="ceo approved")
await self._remove_task_worktree_on_terminal(task)
# Extract learnings (fire-and-forget)
bg_task = asyncio.create_task(self._extract_completion_learnings(task, None))
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
await self.session.flush()
await self._trigger_completion_hooks(task, None)
# Unblock any tasks waiting on this one
await self._unblock_dependents(task_id)
@@ -5334,6 +5455,7 @@ class TaskService(BaseService):
released = False
for child in await self.get_subtasks(cast("UUID", umbrella.id)):
if child.batch_id is not None and child.status == TaskStatus.BACKLOG:
pre_status = child.status.value
child.status = TaskStatus.PENDING
child.team = cast("Any", Team.MAIN_PM)
# a board-routed root-subtask is created in BACKLOG with
@@ -5349,6 +5471,20 @@ class TaskService(BaseService):
task_id=str(child.id),
)
child.task_type = cast("Any", TaskType.PLANNING)
# No status change may bypass the audit log — record the
# backlog→pending activation so the child's lifecycle
# reconstruction (the metric source of truth) keeps its start.
# audit_agent_id is None: approve_and_start receives no approver
# id, and the audit_log.agent_id FK is nullable — attributing a
# hardcoded ceo uuid would FK-violate when the ceo agent row is
# absent. agent_role="ceo" still labels the actor.
self._emit_status_transition_audit(
child,
from_status=pre_status,
to_status=TaskStatus.PENDING.value,
agent_role="ceo",
audit_agent_id=None,
)
released = True
if released:
await self.session.flush()
@@ -5619,12 +5755,63 @@ class TaskService(BaseService):
if not project_slug:
return
await self._remove_task_worktree_best_effort(task, project_slug)
except OSError as e:
# FS/permission failure (stuck mount, perms) — systemic, not
# task-specific. Track the streak and escalate a CEO alert once
# after N consecutive failures so worktrees don't leak silently.
TaskService._worktree_cleanup_fail_streak += 1
self.log.warning(
"Terminal worktree cleanup skipped (FS/permission)",
task_id=str(task.id),
streak=TaskService._worktree_cleanup_fail_streak,
error=str(e),
)
if (
TaskService._worktree_cleanup_fail_streak
>= TaskService._WORKTREE_CLEANUP_ESCALATE_AFTER
and not TaskService._worktree_cleanup_escalated
):
TaskService._worktree_cleanup_escalated = True
self.log.error(
"Recurring worktree-cleanup FS failure escalating to CEO",
streak=TaskService._worktree_cleanup_fail_streak,
task_id=str(task.id),
)
await self._escalate_worktree_cleanup_failure(task, e)
except Exception as e:
# Non-FS failure (git, DB) — task-specific, best-effort, no streak.
TaskService._worktree_cleanup_fail_streak = 0
self.log.warning(
"Terminal worktree cleanup skipped",
task_id=str(task.id),
error=str(e),
)
else:
TaskService._worktree_cleanup_fail_streak = 0
TaskService._worktree_cleanup_escalated = False
async def _escalate_worktree_cleanup_failure(
self, task: TaskTable, exc: BaseException
) -> None:
"""Best-effort CEO alert on recurring worktree-cleanup FS failure."""
try:
from roboco.services.notification import NotificationService
await NotificationService().send_ack_notification(
from_agent="system",
to_agent="ceo",
body=(
f"Recurring worktree-cleanup FS failure "
f"(last error: {str(exc)[:160]}). "
f"Task {task.id} terminal worktree could not be removed — "
"per-task worktrees may be leaking on disk. Investigate the "
"workspace mount / permissions."
),
priority=NotificationPriority.HIGH,
task_id=str(task.id),
)
except Exception as e:
self.log.warning("Worktree-cleanup escalation notify failed", error=str(e))
async def _close_work_session_for_task(self, task: TaskTable, reason: str) -> None:
"""Close the task's work session on successful completion.
@@ -5668,9 +5855,13 @@ class TaskService(BaseService):
# Cancel all descendants first (children, grandchildren, etc.)
# Skip tasks already in terminal states (completed or cancelled).
# Route every descendant through _validate_and_set_status so role
# restrictions (e.g., only CEO can cancel awaiting_ceo_approval) still
# apply to cascaded cancels — skip descendants that fail validation
# rather than bypassing the rules.
# restrictions still apply to cascaded cancels. A non-terminal
# descendant the caller's role can't cancel REFUSES the whole
# cancel — never silently skip it and leave an orphaned subtree
# under a cancelled parent. (The spec gates every cancel edge to
# {cell_pm, main_pm, ceo} uniformly, so a PM/CEO cancel won't hit
# this; the narrow catch + refusal keeps a future per-edge role
# gate from silently orphaning.) Non-validation errors propagate.
descendants = await self.get_all_descendants(task_id)
cancelled_count = 0
for descendant in descendants:
@@ -5680,15 +5871,23 @@ class TaskService(BaseService):
self._validate_and_set_status(
descendant, TaskStatus.CANCELLED, agent_role
)
except Exception as e:
except TaskLifecycleError as e:
self.log.warning(
"Skipping cascade-cancel of descendant; role not permitted",
"Refusing cascade-cancel: caller role cannot cancel descendant",
descendant_id=str(descendant.id),
descendant_status=descendant.status.value,
agent_role=agent_role,
error=str(e),
)
continue
raise TaskLifecycleError(
current_status=descendant.status.value,
target_status=TaskStatus.CANCELLED.value,
message=(
f"Cannot cancel descendant {descendant.id} in state "
f"{descendant.status.value} with role {agent_role!r}; "
"refusing parent cancel to avoid an orphaned subtree."
),
) from e
cancelled_count += 1
await self._abandon_work_session_for_task(
descendant, reason="parent task cancelled"
@@ -8247,7 +8446,9 @@ class TaskService(BaseService):
await self.session.flush()
# This restore path sets the status directly (bypassing the strict
# transition validator), so emit the audit explicitly — no status
# change may skip the audit log.
# change may skip the audit log. A restore to a snapshotted
# needs_revision is the same rework cycle resuming, not a fresh
# rejection, so undo the bump the chokepoint applied above.
self._emit_status_transition_audit(
task,
from_status=pre_status,
@@ -8255,6 +8456,11 @@ class TaskService(BaseService):
agent_role=None,
audit_agent_id=restored_owner,
)
if (
restored_status == TaskStatus.NEEDS_REVISION
and pre_status != TaskStatus.NEEDS_REVISION.value
):
task.revision_count = max((task.revision_count or 1) - 1, 0)
return task
async def cell_pm_complete(
+25 -13
View File
@@ -227,22 +227,34 @@ class TranscriptionService:
while self._running:
try:
await asyncio.sleep(self.config.flush_interval_seconds)
async for buffer in self.get_ready_buffers():
# Notify callbacks
for callback in self._segment_callbacks:
try:
callback(buffer)
except Exception as e:
self.log.error(
"Callback error",
error=str(e),
)
await self._flush_ready_buffers()
except asyncio.CancelledError:
break
except Exception as e:
self.log.error("Periodic flush error", error=str(e))
self.log.error("Periodic flush error", error=e.__class__.__name__)
async def _flush_ready_buffers(self) -> None:
"""One flush pass: notify callbacks for each ready buffer, then remove it.
``get_ready_buffers`` only peeks without flushing, a ready buffer was
re-yielded on every tick and ``_buffers`` grew unbounded (#96). Sync
callbacks are offloaded to a worker thread so a slow callback can't
block the flush task / event loop (#97). A failing callback is logged
and skipped; a sibling callback still runs and the buffer is still
flushed.
"""
async for buffer in self.get_ready_buffers():
for callback in self._segment_callbacks:
try:
await asyncio.to_thread(callback, buffer)
except Exception as e:
self.log.error(
"Callback error",
error=e.__class__.__name__,
)
# Remove the buffer now that its segment has been handed off —
# without this the same ready buffer accumulates forever.
await self.flush_buffer(buffer.agent_id, buffer.session_id)
async def _flush_all(self) -> None:
"""Flush all buffers (called on shutdown)."""
+7 -2
View File
@@ -77,8 +77,13 @@ class UsageService(BaseService):
async def get_summary(self, period: str = "24h") -> dict[str, Any]:
"""Return aggregated token and cost totals for the given period.
Queries daily_usage_rollups for whole-day periods; falls back to
agent_spawn_sessions for sub-day precision.
Sums the RAW ``agent_spawn_sessions`` rows that started within the
period and have closed (``ended_at IS NOT NULL``) sub-day precise, no
rollup dependency. ``daily_usage_rollups`` is the day-grain snapshot
written by the background sweeper (``_rollup_daily_usage``) and read by
``get_today_summary``; the two can diverge for "today" until the sweeper
closes + aggregates still-open sessions (#66 — the prior docstring
falsely claimed this read the rollups).
Returns dict with: tokens_input, tokens_output, total_tokens,
total_cost_usd, trend_pct.
+120
View File
@@ -555,6 +555,126 @@ class WorkspaceService:
await asyncio.to_thread(_ensure_agent_owned, worktree)
await asyncio.to_thread(_ensure_agent_owned, clone_root)
async def _fetch_branch_ref(
self, clone_root: Path, branch: str, project_slug: str
) -> None:
"""Token-aware ``git fetch origin <branch>`` into clone_root. Best-effort.
A clone's ``.git/config`` carries the token-scrubbed remote URL, so a
private-repo fetch needs the token re-injected via ``http.extraheader``
(mirrors ``fetch_branch_for_inspection``). Never raises a failed
fetch falls through to ``ensure_worktree``'s ``-b`` from ``origin/HEAD``.
"""
from roboco.services.project import get_project_service
from roboco.utils.crypto import EncryptionError
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
git_token: str | None = None
if project is not None:
try:
git_token = await project_service.get_decrypted_token_by_slug(
project_slug
)
except EncryptionError:
git_token = None
prefix: list[str] = []
if git_token:
import base64
basic = base64.b64encode(f"x-access-token:{git_token}".encode()).decode()
prefix = ["-c", f"http.extraheader=Authorization: Basic {basic}"]
def _do_fetch() -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"git",
"-C",
str(clone_root),
*prefix,
"fetch",
"--no-tags",
"origin",
branch,
],
capture_output=True,
text=True,
timeout=settings.workspace_refresh_fetch_timeout_seconds,
check=False,
)
result = await asyncio.to_thread(_do_fetch)
if result.returncode != 0:
logger.warning(
"ensure_worktree_self_heal: branch fetch returned non-zero",
branch=branch,
clone_root=str(clone_root),
stderr=result.stderr.strip(),
)
async def ensure_worktree_self_heal(
self,
clone_root: Path,
worktree: Path,
branch: str,
project_slug: str,
) -> None:
"""Re-attach a per-task worktree, self-healing a vanished clone + ref.
F123 spawn-time chokepoint. A resumed agent's clone can vanish (disk
loss, a redeploy that wiped ``/data/workspaces``, manual cleanup) while
the task's DB row keeps ``branch_name`` and the reaper-style claim
release preserves ownership so the next dispatch is a RESUME, not a
fresh claim, and ``create_branch`` never re-runs to re-clone.
``ensure_worktree_for_resume`` then runs ``git -C <clone_root>`` against
a missing directory and fatal-loops (claim released -> re-dispatched
into the same missing clone -> repeat, every tick).
The orchestrator calls ``ensure_workspace`` first when the clone is
unhealthy (re-clone from default). This then recovers the task branch
ref so the worktree re-attaches with the pushed work intact:
1. A present, registered worktree is a no-op (just venv + ownership).
2. If the local ``refs/heads/{branch}`` ref is absent (a re-clone has
none), fetch ``origin <branch>`` (token-aware) and create the local
ref from ``refs/remotes/origin/{branch}`` when origin has it
recovering the pushed commits. (``create_branch`` always pushes at
claim time, so a claimed task's branch is on origin.)
3. ``ensure_worktree`` reuses the recovered ref, or if origin doesn't
have it (a never-pushed branch) re-creates it from ``origin/HEAD``;
no pushed work is lost because none existed.
A transient fetch failure falls through to the ``origin/HEAD`` ``-b``
rather than fatal-looping; a diverged branch re-syncs on the agent's
first ``sync_branch``.
"""
if worktree.exists() and (worktree / ".git").is_file():
self._link_shared_venv(worktree, clone_root)
await asyncio.to_thread(_ensure_agent_owned, worktree)
await asyncio.to_thread(_ensure_agent_owned, clone_root)
return
local = self._worktree_git(
clone_root,
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
check=False,
)
if local.returncode != 0:
await self._fetch_branch_ref(clone_root, branch, project_slug)
remote = self._worktree_git(
clone_root,
["rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
check=False,
)
if remote.returncode == 0:
self._worktree_git(
clone_root,
["branch", branch, f"refs/remotes/origin/{branch}"],
check=False,
)
# Reuse refs/heads/{branch} if recovered; else -b from origin/HEAD.
await self.ensure_worktree(clone_root, worktree, branch, "origin/HEAD")
async def remove_worktree(self, clone_root: Path, worktree: Path) -> None:
"""Remove a per-task worktree (cancel / terminal / reaper evict).
+6
View File
@@ -114,6 +114,12 @@ def _format_field(name: str, field_info: Any) -> str:
def _annot_str(annot: Any) -> str:
if annot is None or annot is type(None):
return "None"
# Drop Annotated[T, ...] metadata (BeforeValidator, etc.) — render the bare
# type so the prompt table never leaks ``BeforeValidator(func=...)`` repr
# (with a memory address) into agent-facing prompt text (#199).
metadata = getattr(annot, "__metadata__", None)
if metadata is not None:
annot = annot.__origin__
origin = get_origin(annot)
if origin is None:
return getattr(annot, "__name__", str(annot))
+15
View File
@@ -29,6 +29,7 @@ from __future__ import annotations
import json
import os
import socket
import warnings
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
@@ -97,7 +98,21 @@ def _postgres_reachable() -> bool:
return False
def _warn_if_pg_unavailable(available: bool, host: str, port: int) -> None:
"""#90: warn loudly when the test DB is unreachable so a green run of all-skips
is not mistaken for a pass. The per-test ``pytest.skip`` still applies; this
only adds a visible import-time warning."""
if not available:
warnings.warn(
f"Postgres unreachable at {host}:{port} — every DB test will be "
"SKIPPED. Set ROBOCO_TEST_DB_HOST/PORT/USER/PASSWORD or start "
"Postgres (local: ROBOCO_TEST_DB_PORT=55432 ROBOCO_TEST_DB_USER=renzof).",
stacklevel=2,
)
_PG_AVAILABLE = _postgres_reachable()
_warn_if_pg_unavailable(_PG_AVAILABLE, _TEST_DB_HOST, _TEST_DB_PORT)
# Sanity-check imported tables registered themselves on Base.metadata. Tied to
# `roboco_tables` so static analysis treats the import as load-bearing.
+20
View File
@@ -4,10 +4,12 @@ from __future__ import annotations
import dataclasses
from roboco import agents_config
from roboco.agents_config import CHANNEL_ACCESS
from roboco.foundation import identity
from roboco.foundation.policy import communications
from roboco.models.base import NotificationPriority, NotificationType
from roboco.seeds import initial_data as seeds_initial_data
def test_priority_enum_matches_notification_priority() -> None:
@@ -141,3 +143,21 @@ def test_parse_priority_explicit_priority_wins_over_legacy_flag() -> None:
communications.parse_priority("normal", legacy_urgent_flag=True)
is communications.Priority.NORMAL
)
def test_team_scoped_roles_is_single_sourced() -> None:
"""#212: ``TEAM_SCOPED_ROLES`` is defined once in the foundation module and
every consumer (agents_config, seeds/initial_data) references THAT object
no duplicate definitions that could drift."""
expected = frozenset(
{
identity.Role.DEVELOPER,
identity.Role.QA,
identity.Role.DOCUMENTER,
identity.Role.CELL_PM,
}
)
assert expected == communications.TEAM_SCOPED_ROLES
# Same object identity — not a re-built copy that could drift.
assert agents_config._TEAM_SCOPED_ROLES is communications.TEAM_SCOPED_ROLES
assert seeds_initial_data._TEAM_SCOPED_ROLES is communications.TEAM_SCOPED_ROLES
@@ -18,10 +18,9 @@ from roboco.services.gateway import content_actions
# Cell-member roles that are subject to a channel's team_scope. Cross-cell
# roles (MAIN_PM, AUDITOR, CEO, board) are NOT filtered — they participate
# regardless of which team a cell channel scopes to.
_TEAM_SCOPED_ROLES: frozenset[Role] = frozenset(
{Role.DEVELOPER, Role.QA, Role.DOCUMENTER, Role.CELL_PM}
)
# regardless of which team a cell channel scopes to. Single-sourced in the
# foundation module under test.
_TEAM_SCOPED_ROLES: frozenset[Role] = communications.TEAM_SCOPED_ROLES
def _expected_slugs(role_set: frozenset[Role], team_scope: Team | None) -> set[str]:
+37
View File
@@ -241,3 +241,40 @@ def test_role_for_slug_or_none_unknown_returns_none() -> None:
def test_role_for_slug_or_none_known_returns_role() -> None:
assert identity.role_for_slug_or_none("be-pm") == identity.Role.CELL_PM
assert identity.role_for_slug_or_none("ceo") == identity.Role.CEO
# ---------------------------------------------------------------------------
# #49: human-only-role + spawnable-slug predicates (layered skip guards)
# ---------------------------------------------------------------------------
def test_is_human_only_role_for_each_human_role() -> None:
for role in (identity.Role.CEO, identity.Role.PROMPTER, identity.Role.SECRETARY):
assert identity.is_human_only_role(role) is True
def test_is_human_only_role_false_for_agent_roles_and_none() -> None:
assert identity.is_human_only_role(identity.Role.DEVELOPER) is False
assert identity.is_human_only_role(identity.Role.QA) is False
assert identity.is_human_only_role(identity.Role.CELL_PM) is False
assert identity.is_human_only_role(None) is False
def test_is_spawnable_agent_slug_true_for_known_non_human() -> None:
assert identity.is_spawnable_agent_slug("be-dev-1") is True
assert identity.is_spawnable_agent_slug("be-qa") is True
assert identity.is_spawnable_agent_slug("be-pm") is True
def test_is_spawnable_agent_slug_false_for_live_human_slugs() -> None:
# ceo / intake-1 (prompter) / secretary-1 are the seeded human-only slugs.
for slug in ("ceo", "intake-1", "secretary-1"):
assert identity.is_spawnable_agent_slug(slug) is False
def test_is_spawnable_agent_slug_false_for_stale_slug() -> None:
"""#49: a slug that resolves to no known agent must not be treated as
spawnable a stale/ex-human slug must not slip past a skip guard that
only checks ``role in (CEO, PROMPTER, SECRETARY)`` (None is not in it)."""
assert identity.is_spawnable_agent_slug("old-renamed-secretary") is False
assert identity.is_spawnable_agent_slug("does-not-exist-in-agents") is False
+72 -1
View File
@@ -247,6 +247,10 @@ def test_status_transitions_includes_ceo_paths() -> None:
) in sources
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.COMPLETED) in sources
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.NEEDS_REVISION) in sources
# #100: a branchless coordination root rejected by the CEO routes to PENDING
# (Main PM re-plans) — the edge is in the spec so the audited privileged
# override that applies it can't be wedged by future admin-override tightening.
assert (spec.Status.AWAITING_CEO_APPROVAL, spec.Status.PENDING) in sources
# A blocked task the PM cannot resolve can also be surfaced to the CEO.
assert (spec.Status.BLOCKED, spec.Status.AWAITING_CEO_APPROVAL) in sources
@@ -717,7 +721,12 @@ def test_valid_next_verbs_developer_in_progress_includes_open_pr_and_i_am_done()
def test_valid_next_verbs_pm_pending_includes_i_will_plan() -> None:
verbs = spec.valid_next_verbs(spec.Role.CELL_PM, _stub_task(status="pending"))
# A PM i_will_plan's a PLANNING task (coordination), not a code task — the
# PM/code claim carve-out (Fix 2) removes i_will_plan from a pending code
# task's verb set, so the legitimate path is exercised with task_type=planning.
verbs = spec.valid_next_verbs(
spec.Role.CELL_PM, _stub_task(status="pending", task_type="planning")
)
assert "i_will_plan" in verbs
@@ -891,3 +900,65 @@ def test_unmigrated_is_pinned() -> None:
)
== spec.UNMIGRATED
)
# --- PM/code claim invariant (Fix 2 + bug-1): the claim gate does NOT block a
# PM claiming a code task. A PM's only claim verb is i_will_plan, and planning a
# code-typed PARENT (to decompose + delegate the code) is legitimate (bug-1:
# scoping pm_cannot_execute_code to i_will_plan deadlocked the slice). Execution
# is blocked at the intent level — i_will_work_on is _DEV_ROLES only. The
# create/delegate guards (pm_cannot_own_code) block a PM from being ASSIGNED a
# fresh code task; the needs_revision carve-out (a PM resolving review issues
# directly / recovering a rejected coordination task) is naturally allowed
# because PMs claim NEEDS_REVISION. These pin that the claim gate does not
# regress bug-1.
def _claim_task(*, status: str, task_type: str) -> Any:
return SimpleNamespace(status=status, task_type=task_type)
def test_claim_allows_cell_pm_claiming_code_from_pending() -> None:
"""bug-1: a cell PM i_will_plan-ing a code-typed parent (PENDING) to plan +
delegate the code MUST be allowed rejecting it deadlocks the slice."""
t = _claim_task(status="pending", task_type="code")
d = spec.can_invoke_action(spec.Role.CELL_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_cell_pm_claiming_code_from_needs_revision() -> None:
"""Carve-out: a PM may take a code task in needs_revision to resolve the
review/QA issues directly / recover a rejected coordination task."""
t = _claim_task(status="needs_revision", task_type="code")
d = spec.can_invoke_action(spec.Role.CELL_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_main_pm_claiming_code_from_needs_revision() -> None:
"""The same carve-out holds for the Main PM (coordination-recovery path)."""
t = _claim_task(status="needs_revision", task_type="code")
d = spec.can_invoke_action(spec.Role.MAIN_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_main_pm_claiming_code_from_pending() -> None:
"""bug-1 parity: a Main PM planning a code-typed parent (PENDING) is allowed
for the same reason as the cell PM execution is blocked at i_will_work_on,
not at the claim gate."""
t = _claim_task(status="pending", task_type="code")
d = spec.can_invoke_action(spec.Role.MAIN_PM, "claim", t)
assert d.allowed, d.message
def test_claim_allows_pm_claiming_planning_from_pending() -> None:
"""A PM claiming a planning task is the legitimate coordination path."""
t = _claim_task(status="pending", task_type="planning")
assert spec.can_invoke_action(spec.Role.CELL_PM, "claim", t).allowed
assert spec.can_invoke_action(spec.Role.MAIN_PM, "claim", t).allowed
def test_claim_allows_developer_claiming_code_from_pending() -> None:
"""A developer claiming fresh code is unaffected (the PM invariant is
enforced at create/delegate + i_will_work_on, not the claim gate)."""
t = _claim_task(status="pending", task_type="code")
assert spec.can_invoke_action(spec.Role.DEVELOPER, "claim", t).allowed
+18
View File
@@ -76,6 +76,24 @@ def test_submit_root_is_main_pm_only_and_opens_a_pr() -> None:
assert iv.pre_side_effects == ("create_root_pr",)
def test_submit_root_rejects_a_code_typed_root() -> None:
"""#148: submit_root's prose asserts 'a Main-PM root is planning-typed,
never code' — the spec must back that claim, not only the upstream creation
guard (``main_pm_cannot_own_code``). A code-typed root is rejected at the
spec gate as invalid_state (defense in depth: such a root is already
unreachable via creation, but the spec mustn't accept it either)."""
t = _task("in_progress", team="main_pm", task_type="code")
d = spec.can_invoke_intent(Role.MAIN_PM, "submit_root", t)
assert not d.allowed
assert d.rejection_kind == "invalid_state"
def test_submit_root_allows_a_planning_typed_root() -> None:
"""The legitimate Main-PM root shape — planning-typed, in_progress — passes."""
t = _task("in_progress", team="main_pm", task_type="planning")
assert spec.can_invoke_intent(Role.MAIN_PM, "submit_root", t).allowed
def test_gate_cannot_skip_straight_to_terminal_or_ceo() -> None:
targets = spec.STATUS_GRAPH[Status.AWAITING_PR_REVIEW]
assert Status.COMPLETED not in targets
@@ -70,7 +70,9 @@ async def _get_or_create_agent(
await db.flush()
async def _seed_project(db: AsyncSession, slug: str, git_url: str) -> ProjectTable:
async def _seed_project(
db: AsyncSession, slug: str, git_url: str, *, workflow: str | None = None
) -> ProjectTable:
project = ProjectTable(
id=uuid4(),
name=slug,
@@ -79,6 +81,7 @@ async def _seed_project(db: AsyncSession, slug: str, git_url: str) -> ProjectTab
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
ci_watch_enabled=True,
ci_watch_workflow=workflow,
)
db.add(project)
await db.flush()
@@ -157,3 +160,52 @@ async def test_disabled_is_noop(
proj = await _seed_project(db_session, "red-e", "https://github.com/x/e.git")
src = _FakeSource([_breach("red-e")])
assert await get_ci_watch_engine(db_session, source=src).run_cycle([proj]) == []
# ---------------------------------------------------------------------------
# #44: dedupe by (git_url, workflow) — a multi-workflow monorepo with two red
# workflows gets a fix task per workflow, not one collapsed task per repo
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_two_red_workflows_of_one_monorepo_both_open(
db_session: AsyncSession,
) -> None:
"""#44: same git_url, DIFFERENT workflows, both red → two fix tasks."""
git = "https://github.com/x/mono.git"
p_a = await _seed_project(db_session, "mono-wf-a", git, workflow="wf-a.yml")
p_b = await _seed_project(db_session, "mono-wf-b", git, workflow="wf-b.yml")
src = _FakeSource([_breach("mono-wf-a"), _breach("mono-wf-b")])
projects = [p_a, p_b]
created = await get_ci_watch_engine(db_session, source=src).run_cycle(projects)
assert len(created) == len(projects)
assert {c.project_id for c in created} == {p_a.id, p_b.id}
@pytest.mark.asyncio
async def test_same_git_url_same_workflow_still_deduped(
db_session: AsyncSession,
) -> None:
"""Regression guard: two cell-projects on one repo, SAME workflow → one task
(the (git_url, workflow) key still collapses a same-workflow monorepo)."""
git = "https://github.com/x/mono2.git"
p1 = await _seed_project(db_session, "mono2-a", git, workflow="wf-a.yml")
p2 = await _seed_project(db_session, "mono2-b", git, workflow="wf-a.yml")
src = _FakeSource([_breach("mono2-a"), _breach("mono2-b")])
created = await get_ci_watch_engine(db_session, source=src).run_cycle([p1, p2])
assert len(created) == 1
@pytest.mark.asyncio
async def test_default_workflow_null_rows_deduped(
db_session: AsyncSession,
) -> None:
"""Two NULL-workflow project rows on one repo both use the default workflow
deduped to one task (coalesce treats NULL as the default workflow)."""
git = "https://github.com/x/mono3.git"
p1 = await _seed_project(db_session, "mono3-a", git) # ci_watch_workflow=None
p2 = await _seed_project(db_session, "mono3-b", git)
src = _FakeSource([_breach("mono3-a"), _breach("mono3-b")])
created = await get_ci_watch_engine(db_session, source=src).run_cycle([p1, p2])
assert len(created) == 1
@@ -124,6 +124,59 @@ async def test_corrupt_file_falls_back_to_last_ok(
assert health.last_ok_sha == "ok1"
async def test_health_recovers_after_in_place_repair(
db_session: AsyncSession, tmp_path: Path
) -> None:
"""#132: ``health`` re-reads the LIVE file status — a cached ``degraded``
row must not hide an in-place repair at the same (stale) head key."""
project = await _seed_project(
db_session, head_commit="h", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text("modules: [unterminated\n")
svc = get_conventions_service(db_session)
await svc.get_map(project) # caches the degraded state
assert (await svc.health(project)).status == "degraded"
# Repair the file in place at the SAME head key.
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/fixed\n purpose: fixed\n"
)
assert (await svc.health(project)).status == "ok"
async def test_get_map_recovers_after_in_place_repair(
db_session: AsyncSession, tmp_path: Path
) -> None:
"""#132: ``get_map`` does not serve a cached ``degraded`` (last-good) map
once the file is repaired in place it re-derives the fixed map."""
project = await _seed_project(
db_session, head_commit="ok1", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/special\n purpose: special\n"
)
svc = get_conventions_service(db_session)
await svc.get_map(project) # caches ok1 (lib/special)
project.head_commit = "bad1"
await db_session.flush()
(conv / "conventions.yml").write_text("modules: [unterminated\n")
degraded = await svc.get_map(project) # falls back to last_good (lib/special)
assert any(m.path == "lib/special" for m in degraded.modules)
# Repair in place at the same bad1 head with a NEW module.
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/fixed\n purpose: fixed\n"
)
recovered = await svc.get_map(project)
assert any(m.path == "lib/fixed" for m in recovered.modules)
assert not any(m.path == "lib/special" for m in recovered.modules)
async def test_baseline_constraints_include_block_rules(
db_session: AsyncSession, tmp_path: Path
) -> None:
@@ -634,3 +634,34 @@ async def test_get_dashboard_service_factory(
) -> None:
svc = get_dashboard_service(db_session)
assert isinstance(svc, DashboardService)
@pytest.mark.asyncio
async def test_auditor_flags_are_in_memory_and_lost_on_storage_reset(
db_session: AsyncSession,
) -> None:
"""Auditor flags/reports live in a process-singleton in-memory store, NOT
the DB (#68 — ``DashboardStorage`` docstring: 'In production, these would be
database tables'). A reset (the test/prod-restart analog) drops them. This
test pins the documented limitation so a silent persistence regression is
caught and the stub is not mistaken for a durable store. Persisting to real
tables is a migration + service refactor (a feature), deliberately not
half-implemented here.
"""
reset_storage()
svc = DashboardService(db_session)
flag = svc.create_flag(
CreateFlagParams(
severity="high",
category="quality",
title="stale-claim",
description="be-dev-1 heartbeat stale",
related_agent_id=uuid4(),
)
)
assert flag.id in get_storage().flags
# A process restart / test reset drops the in-memory store.
reset_storage()
assert flag.id not in get_storage().flags
assert get_storage().flags == {}
+152 -3
View File
@@ -254,7 +254,7 @@ async def test_write_doc_creates_new_subfolder_empty(
@pytest.mark.asyncio
async def test_write_doc_updates_existing(docs_setup: dict, tmp_path: Path) -> None:
"""When _find_similar_doc returns a path, update path is taken."""
"""When _find_similar_doc returns a path AND the filename matches, update."""
svc = docs_setup["svc"]
existing_path = "backend/api/existing.md"
with (
@@ -266,7 +266,7 @@ async def test_write_doc_updates_existing(docs_setup: dict, tmp_path: Path) -> N
agent_id="be-doc",
req=WriteDocInput(
task_id=docs_setup["task_id"],
filename="example.md",
filename="existing.md",
doc_type="api",
title="New Title",
content="# Updated",
@@ -311,7 +311,7 @@ async def test_write_doc_update_preserves_existing_metadata(
agent_id="be-doc",
req=WriteDocInput(
task_id=docs_setup["task_id"],
filename="example.md",
filename="existing.md",
doc_type="api",
title="New Title",
content="# Updated",
@@ -323,6 +323,155 @@ async def test_write_doc_update_preserves_existing_metadata(
assert doc_ref.updated_by == "be-doc"
@pytest.mark.asyncio
async def test_write_doc_no_collapse_on_different_filename(
docs_setup: dict, tmp_path: Path
) -> None:
"""#35: a similar doc with a DIFFERENT filename must not be overwritten —
the agent named a new file, so create it instead of collapsing onto the
similar doc's path."""
svc = docs_setup["svc"]
existing_path = "backend/api/existing.md"
with (
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=existing_path)),
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
):
rel_path, _doc_ref, is_update = await svc.write_doc(
agent_id="be-doc",
req=WriteDocInput(
task_id=docs_setup["task_id"],
filename="other.md",
doc_type="api",
title="New Title",
content="# New",
),
)
assert is_update is False
# A new file is created at the requested filename, NOT the similar doc path.
assert rel_path.endswith("other.md")
assert rel_path != existing_path
@pytest.mark.asyncio
async def test_write_doc_update_path_containment_checked(
docs_setup: dict, tmp_path: Path
) -> None:
"""#33: the RAG-returned update path is containment-checked — a ``source``
that escapes the docs dir (``../../etc/evil.md``) is refused, not written."""
svc = docs_setup["svc"]
escaping = "../../etc/evil.md"
with (
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=escaping)),
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
pytest.raises(ValidationError),
):
await svc.write_doc(
agent_id="be-doc",
req=WriteDocInput(
task_id=docs_setup["task_id"],
filename="evil.md",
doc_type="api",
title="Title",
content="# x",
),
)
@pytest.mark.asyncio
async def test_write_doc_commit_status_skipped_when_no_branch(
docs_setup: dict, tmp_path: Path
) -> None:
"""#34: when there is no task branch to commit onto, the doc still saves to
/app/docs and the doc_ref carries ``commit_status='skipped'`` (not a silent
None) so the agent knows the repo commit did not happen."""
svc = docs_setup["svc"]
with (
patch("roboco.services.docs.DOCS_BASE_PATH", tmp_path),
patch.object(svc, "_find_similar_doc", AsyncMock(return_value=None)),
patch.object(svc, "_index_doc_in_rag", AsyncMock(return_value=None)),
):
_rel, doc_ref, _is_update = await svc.write_doc(
agent_id="be-doc",
req=WriteDocInput(
task_id=docs_setup["task_id"],
filename="example.md",
doc_type="api",
title="Title",
content="# Hello",
),
)
# The fixture task has no branch_name → commit is skipped, not silent.
assert doc_ref.commit_status == "skipped"
@pytest.mark.asyncio
async def test_commit_doc_to_repo_returns_failed_on_git_error(
docs_setup: dict,
) -> None:
"""#34: a git hiccup surfaces as ``failed`` (fail-loud), not a swallowed
None the agent can tell the cell PM the doc did not reach the repo."""
svc = docs_setup["svc"]
# Give the task a branch + project so the commit path is entered.
result = await svc.session.execute(
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
)
task = result.scalar_one()
task.branch_name = "feature/docs"
await svc.session.flush()
fake_git = MagicMock()
fake_git.get_workspace = AsyncMock(side_effect=RuntimeError("git boom"))
with patch("roboco.services.git.get_git_service", return_value=fake_git):
status = await svc._commit_doc_to_repo(
"be-doc",
WriteDocInput(
task_id=docs_setup["task_id"],
filename="example.md",
doc_type="api",
title="Title",
content="# Hello",
),
"api",
)
assert status == "failed"
@pytest.mark.asyncio
async def test_commit_doc_to_repo_returns_committed_on_success(
docs_setup: dict, tmp_path: Path
) -> None:
"""#34: a successful repo commit reports ``committed``."""
svc = docs_setup["svc"]
result = await svc.session.execute(
select(TaskTable).where(TaskTable.id == docs_setup["task_id"])
)
task = result.scalar_one()
task.branch_name = "feature/docs"
await svc.session.flush()
fake_git = MagicMock()
fake_git.get_workspace = AsyncMock(return_value=tmp_path)
fake_git.commit = AsyncMock(return_value={"oid": "abc"})
with (
patch("roboco.services.git.get_git_service", return_value=fake_git),
patch.object(svc, "_write_file", AsyncMock(return_value=None)),
):
status = await svc._commit_doc_to_repo(
str(docs_setup["agent_id"]),
WriteDocInput(
task_id=docs_setup["task_id"],
filename="example.md",
doc_type="api",
title="Title",
content="# Hello",
),
"api",
)
assert status == "committed"
# ---------------------------------------------------------------------------
# _find_similar_doc
# ---------------------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More