mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(video): rewrite sidecar render core to HyperFrames (in place)
* feat(video): convert motion compositions from Remotion TSX to HyperFrames HTML
* refactor(video): rename render client to video_renderer_client (renderer-agnostic)
* chore(video): rename remotion-renderer prose in test_video_pipeline docstrings
* chore(video): rename sidecar to video-renderer + add system ffmpeg for HyperFrames
* chore(video): rename stray remotion-renderer refs in sidecar + py docstrings (controller cleanup)
* chore(video): fix stale Remotion API names in Dockerfile comment (controller cleanup)
* docs(video): rewrite video-engine prose for HyperFrames + add map entry + folded prose fixes
* docs(video): add trailing newline to docs/map/video-engine.md (controller cleanup)
* chore(video): drop internal spec refs + minio/test suppressions (folded hygiene)
* fix(video): reclaim outDir on createRenderJob throw + hide empty 4th highlight
Final whole-branch review (Opus) triaged two FIX items from the SDD nits
ledger; the rest ship as-is.
- render.js: a synchronous throw from createRenderJob (post-mkdtemp, not
awaited) left an empty outDir on disk — the outer catch only reclaimed
extractDir. Reclaim outDir too when it exists, and correct the stale
comment that claimed the out dir was never created.
- {vertical,square}.html: the 4th highlights <li> lived in the DOM hidden
only by JS, so a no-JS / failed-script render would show an empty bullet.
Start it style="display:none" and reveal on populate, so an unscripted
render shows nothing instead.
Vitest smoke (release-announcement.test.js) 4/4 green; render.js syntax
checked. Python suite untouched by this fix (JS/HTML only).
* fix(video): type _override_db yield as AsyncSession | None
T7 widened _build_app's db_session param to AsyncSession | None (to drop the
4x # type: ignore[arg-type] on the DB-independent _build_app(None, ...) calls)
but left the inner _override_db fixture typed AsyncIterator[AsyncSession] —
so 'yield db_session' yielded AsyncSession | None into a declared AsyncSession,
and mypy failed at test_video_routes.py:177 ('Incompatible types in yield').
The DB-independent media tests pass db_session=None deliberately: their route
uses a monkeypatched task service and never awaits the session, so yielding
None is safe at runtime. Type the override's yield as AsyncSession | None to
match — no cast, no # type: ignore, no assert, runtime behavior unchanged.
The 3 media tests (3 passed) and the 19 db-gated tests (skipped locally) hold.
* chore(gate): skip .superpowers scratch in markdown prose gate
reflow_md.py walks the filesystem via rglob('*.md') and skips tooling dirs
(.venv, .mypy_cache, .pytest_cache, ...) but not .superpowers/ — the
superpowers SDD workflow's scratch dir (briefs, reports, progress ledger,
all gitignored). A dev running SDD locally would hit a false markdown-prose
gate failure on those transient files. Add .superpowers to SKIP_DIRS,
consistent with the existing tooling-scratch exclusions.
* fix(video): validate composition_id to close path traversal (CodeQL)
compositionId flowed unvalidated from the POST body into path.join
under extractDir/motion/compositions/, so a '../..'-style value could
escape the composition dir (CodeQL: Uncontrolled data used in path
expression). Validate at the trust boundary in server.js
(/^[A-Za-z0-9_-]+$/) and add a path.resolve + startsWith containment
check in render.js so it stays safe regardless of caller.
* fix(mcp): send X-Agent-Token + X-Agent-Team from flow/do servers
flow_server._build_headers and do_server._build_headers constructed
only X-Agent-ID/Role/Correlation-ID, omitting X-Agent-Token and
X-Agent-Team (unlike ApiClient._get_agent_headers used by the other
MCP servers). Latent since the gateway refactor — surfaced when
ROBOCO_AGENT_AUTH_REQUIRED=true was armed on the NAS, 401-ing every
flow/do verb with 'Missing X-Agent-Token header'. Add both headers
(mirroring ApiClient) so the HMAC gate passes. Tests assert the
headers are now injected.
* [video-engine] Per-project video_engine_enabled opt-in toggle
Mirrors ci_watch_enabled (migration 048): the global
ROBOCO_VIDEO_ENGINE_ENABLED flag arms the subsystem; the new
projects.video_engine_enabled column (migration 063) opts a repo into
authoring against its motion/ dir. VideoEngine._opted_in_project no-ops
open_video_task at the single chokepoint covering all three trigger
paths (on-release, on-spotlight, CEO on-demand) until the operator
flips it in the panel edit-project dialog. Existing projects stay
opted out (server_default=false).
* fix(auth): send X-Agent-Token + X-Agent-Team from all agent->API call sites
The prior fix (6ed4e139) covered the flow/do MCP servers but missed four
other agent->orchestrator call sites that built the header dict by hand
and omitted X-Agent-Token and/or X-Agent-Team. With ROBOCO_AGENT_AUTH_REQUIRED
armed on the NAS, every one 401s:
- agent_sdk/server.py: the session-end post-mortem flush
(/api/journals/me/entries), A2A persistence + offline fallback
(/api/a2a/*), and the stopped-without-transition auto-substitute
(/api/tasks/auto-substitute) — all sent only X-Agent-ID/Role, so each
401'd 'Missing X-Agent-Token'. Add a shared _agent_headers() helper
(mirroring flow_server._build_headers) and route all four through it.
- agent_sdk/secretary_driver.py: _headers() sent the token but not the
team, so the HMAC gate 401'd with signature mismatch (secretary is
board-team; token signed with team='board', verified with team='').
Add the team header.
- mcp/git_readonly.py: the read-only git MCP sent only X-Agent-ID/Role
— no token, no team — so /api/git/* 401'd once auth was armed. Convert
the static _HEADERS to a _headers() helper with team + token.
- runtime/orchestrator.py: the cell-PM auto-submit self-API call acted
as a PM with a hand-built {X-Agent-ID, X-Agent-Role} dict — no token,
no team — 401ing under auth-required. Add _agent_api_headers(uuid,
role) mirroring _system_api_headers, and use it.
Tests: _agent_headers round-trip (token + team, team-omitted when None),
_agent_api_headers carries a signed PM token + team.
* [auth] Omit UNSIGNED self-call token in dev mode + video-engine test mypy fix
_agent_api_headers sent the UNSIGNED sentinel when ROBOCO_AGENT_AUTH_SECRET
was unset, but the dev-mode middleware rejects a presented-but-unverifiable
token with 401 signature mismatch (while accepting a missing one). The
cell-PM auto-submit self-call 401'd in every dev run, regressing
test_auto_submit_cuts_the_pm_turn. Attach the token only when a secret is
set. Also fix the FromClause.update mypy error in the per-project
video-engine opt-out test (ORM row load + flush).
* [auth] Omit UNSIGNED agent token at every agent->API call site
The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC secret
is unset at spawn. The API middleware rejects a presented-but-unverifiable
token with 401 'signature mismatch' even in dev mode (auth not required),
so forwarding UNSIGNED turned every flow/do/SDK/secretary/git verb into a
401 — the live pr_reviewer/i_am_idle signature-mismatch loop. Omit the
header when the token is the UNSIGNED sentinel at all five agent-side
header builders; dev accepts a missing token, prod 401s with 'Missing
X-Agent-Token' (the clear respawn-with-secret signal). Add a structlog
diagnostic on the middleware reject path so the next mismatch logs the
exact (id, role, team, token_unsigned, auth_required) inputs.
* [auth] Self-heal stale agent tokens at orchestrator startup
A token is signed once at spawn. If ROBOCO_AGENT_AUTH_SECRET drifts
afterwards (a .env change, a compose recreate that reloads the
orchestrator's env without recreating agent containers, an image
redeploy), the surviving agent keeps sending its old token and the
middleware 401s every verb with 'signature mismatch'. The container
stays alive heartbeating, so the reaper never reclaims it and no fresh
agent spawns: the fleet stalls.
_heal_stale_agent_tokens runs at startup (before _readopt_running_agents)
and kills each running agent container whose baked-in token no longer
verifies against the current secret, so normal dispatch re-spawns it
with a freshly signed token. Inert when the secret is unset (dev):
verify fails for every token without a secret, so the heal would kill
the whole fleet without this gate. Best-effort: a probe failure leaves
the container alone (the reaper still covers it).
* [auth] Sign agent token over the UUID, not the slug (pr_reviewer 401 root cause)
The token was signed over the agent slug (_append_agent_auth_env) while the
MCP servers send X-Agent-ID as the agent UUID (_generate_mcp_config, since
453a7ae2 — gateway v1 parses X-Agent-ID as Annotated[UUID]). The middleware
verified HMAC(uuid:role:team) against a slug-signed token → 'signature
mismatch', token_unsigned=false. Latent for 2 months until 6ed4e139/53391f22
made the MCP servers forward the token.
The c0328971 startup heal missed it: docker exec printenv reads the
container-level ROBOCO_AGENT_ID (the slug), so the heal verified the
slug-signed token against the slug → matched → didn't kill the stale
container, which kept 401ing (its MCP server sends the UUID).
Fix: sign the token over the UUID, set the container ROBOCO_AGENT_ID to the
UUID too (so the SDK server — which inherits container env, not the MCP
manifest env — sends UUID consistently), and resolve the container-env id to
its UUID in _heal_stale_agent_tokens so pre-fix stale containers are evicted
on next restart. Regression test: test_heal_kills_slug_env_container_with_slug_signed_token.
* [scan] gate A2A/notification/stream agent-id deps under cloud auth (C1)
* [scan] omit UNSIGNED agent token from MCP server headers (H1)
* [scan] fail loud when cloud auth and nginx CEO-token are both armed (H2)
* [scan] cache last-known-good auth-probe result in panel proxy (C2)
* [respawn] Tripped breaker self-heals after a cooldown
A DB-durable PM-respawn counter (migration 051 / e2f7097a) wedges forever
once tripped: the only reset was a task status change, which can't happen
while the breaker blocks the spawn. So a deploy that fixes the underlying
loop (auth/prompt/schema) couldn't clear the wedge without manual DELETE
surgery on respawn_tracker — the 2026-07-06 pr-reviewer-1 loop, where the
auth fix cleared the 401 but count=63 survived restart and kept skipping
the dispatcher spawn for an external-PR task.
Freeze last_check at the trip tick and, after pm_respawn_trip_cooldown_seconds
(default 300), let ONE spawn through. A still-wedged task re-trips after the
threshold (bounded re-burn ~3 spawns per window); a fixed one advances and
the status-change path fully resets. Restore re-stamps last_check to now, so
a freshly restored row still trips immediately — durability preserved, which
is why the migration-051 persistence tests still pass.
* [scan] fix test_deps callsites for cloud-auth-gate signature change (C1 followup)
* [scan] per-IP rate limit on /auth/login under cloud auth (L31)
* [scan] Phase 1 auth/security fixes under 0.19.0 CHANGELOG
* [scan] secretary token signs over real team (board) not empty — fixes /api/secretary/* 401 (L31-class)
* [scan] LoginRateLimiter: key off X-Forwarded-For first hop + redis-down fail-open test
nginx is the single entry point; request.client.host is the nginx peer IP,
collapsing every external client into one limiter bucket (self-DoS amp).
Read the downstream client IP from X-Forwarded-For (first hop) / X-Real-IP,
falling back to the peer. Adds coverage for the XFF keying, the redis-down
fail-open branch, and drops a redundant asyncio marker on a sync-TestClient
test.
* [scan] nits: describe login_max_attempts + replace cast with assert in get_current_agent_slug
login_max_attempts was the only bare cloud-auth field; add a Field
description matching the surrounding idiom. Replace cast('str', ctx.slug)
with a runtime assert that fails loud if the cloud-auth ctx invariant
breaks, and drop the now-unused cast import.
* [scan] secretary token: use get_agent_team resolver + complete spawn-shutdown mock team (0dfd45ca followup)
* [scan] require agent HMAC token under cloud_auth (close v1 flow/do header-trust)
* [scan] _require_ceo accepts CEO session cookie under cloud_auth
* [scan] HTTP require_panel_token accepts session cookie under cloud_auth
* [scan] gate /api/settings behind panel token
* [scan] gate unauthenticated /api read routes (agents/a2a-tasks/kanban/usage/rate-limits)
* [scan] hoist deferred test imports to top-level (clear PLC0415)
* [scan] Phase 1b e2e smoke + CHANGELOG
* [scan] add_dependency rejects self-reference + cycle (M18)
* [scan] WorkSessionService.create translates IntegrityError to ConflictError (H10)
* [scan] _qa_or_doc_claim locks the task row FOR UPDATE (M19)
* [scan] docs_complete + mark_pr_created lock the task row FOR UPDATE (H4)
* [scan] gate complete() IN_PROGRESS on leaf/branchless only (H3)
* [scan] _unclaim_from_blocked clears stale pre-block snapshot (H5)
* [scan] admin_set_status terminal guard + skip revision bump under force (M20)
* [scan] cell_pm_complete idempotent pre-check before merge (H7)
* [scan] wrap gateway post-runner side effects in try/except (H6)
* [scan] pass_qa/fail_qa accept AWAITING_QA only (L29)
* [scan] mark_pr_created passes audit_agent_id (L30)
* [scan] phase 2 e2e smoke - one scenario per finding
* [scan] phase 2 quality gate
ruff format + check: green
mypy roboco/: green (357 files)
pytest unit+integration: 6905 passed, 10 pre-existing DB-contamination
failures (pass in isolation)
e2e smoke: 11 passed, 4 cross-scenario workspace-contamination failures
(all 6 state-machine scenarios pass individually)
Quality-gate fixes:
- move function-local imports to module top (PLC0415)
- fix M19 regression: submit_for_qa clears active_claimant_id so the
competing-claimant guard lets the QA claim through
- fix H7 regression: _StubGit gains is_pr_merged_for_task
- fix M19 unit tests: mock session.execute for the FOR UPDATE lock
- e2e H3: notes >= 20 chars; e2e H5: rich i_will_work_on inputs +
PM unclaims (block reassigns to PM)
* [scan] move active_claimant_id clear into pass_qa/fail_qa + admin_set_status (M19 follow-on)
Phase 2 opus whole-branch review found the M19 follow-on clear lived in
the gateway wrappers (qa_pass/qa_fail) not the transition methods
(pass_qa/fail_qa) themselves. The direct REST routes POST /pass-qa and
POST /fail-qa call the transitions directly, bypassing the wrappers and
leaving the QA's stale active_claimant_id set in AWAITING_DOCUMENTATION
/ NEEDS_REVISION — the competing-claimant guard then rejects the next
legitimate documenter/QA claim. admin_set_status had the same gap for a
non-blocked override into a review/queue state (IN_PROGRESS->AWAITING_QA
left the dev's id, blocking qa_claim).
Root-cause fix: move the clear INTO pass_qa and fail_qa (mirroring
submit_for_qa), add a clear in admin_set_status when
new_status in _REVIEW_QUEUE_STATES and from_status != BLOCKED, and drop
the now-redundant clears + flushes from the qa_pass/qa_fail wrappers.
Every caller is covered; the wrappers keep their actor-mismatch warnings.
Covering tests: test_pass_qa_clears_active_claimant_for_doc_claim
(asserts a subsequent doc_claim succeeds), test_fail_qa_clears_active_claimant,
test_admin_set_status_into_review_queue_clears_active_claimant,
test_admin_set_status_non_review_queue_keeps_active_claimant. Updated
the two wrapper unit tests that asserted the wrapper clears (now the
transition's job).
* [C3] unindex_journal_entry + call from delete_entry
JournalService.delete_entry deleted the DB row but never de-indexed the
RAG chunks, so deleted/private journal content bled forever into RAG
answers and claim-time briefings. Add OptimalService.unindex_journal_entry
mirroring unindex_playbook (vector-store delete_by_source + tracking-row
delete via get_db_context, both idempotent + best-effort), and call it
from delete_entry after the row commit inside a try/except so a de-index
failure never errors the delete.
* [M25] learning_id hashes full content to avoid collision
The memory distiller emits lessons with a fixed 'Problem: …' opening
shape, so two distinct lessons whose first 100 chars match collided on
learning_id = f"lrn-{md5(content[:100])[:12]}". replace_on_reingest then
routed both to the same source URI and the second ingest's replace_chunks
DELETE wiped the first lesson's chunks — silent data loss.
Hash the full content (widening the hex slice 12→16) so distinct bodies
get distinct ids and each retains its chunks.
* [H13] reject non-internal local_llm_base_url at config load
* [M28] bulk-insert learning broadcast instead of N+1
* [M27] mark_read/mark_all_read stamp only the unread rows seen at call time
mark_read and mark_all_read used to zero the unread counter FIRST, then run
a bulk UPDATE … WHERE read_at IS NULL that stamped every inbound unread row.
A send_chat_message committing between the counter-zero and the UPDATE
inserted a new read_at NULL row that the UPDATE then stamped as read — the
new message was silently consumed while the counter stayed 0.
Mirrors get_unread_messages (same file): SELECT the unread message IDs at
call time, UPDATE exactly those IDs, then recompute the unread counter from
the DB via the existing _reset_unread_counter helper. A message arriving
mid-call is not in the selected ID set, so the UPDATE skips it and the
recomputed counter keeps it unread.
* [H12] dedup: exact to_agents predicate + purpose discriminator + ack DEL
* [M23] playbook indexed_ok/indexed_at + startup reconcile of unindexed approved
* [M24] RAG indexing dead-letter + janitor reclaim + failed_index_count health
* [L23] institutional_memory_status sentinel distinguishes below-floor/empty/error/disabled
* [L26] sweep_expired_notifications re-escalates stale unacked ack-required
* [phase3] e2e smoke + CHANGELOG for 0.19.0
* [M24] _reindex_journal_entry honors is_private (C1 review fix)
Dead-letter replay mirrors the original journal._schedule_rag_index path:
a private entry is never indexed into the shared JOURNALS corpus, and a
private learning is still recorded into LEARNINGS as non-shareable.
Previously the replay always called index_journal_entry and skipped
record_learning for private learnings, leaking private content on replay
and dropping the legitimate non-shared learning. Three regression tests.
* [H11] clone via git -c http.extraheader, not URL-embedded PAT
* [H11] _sync_read_clone fetch via http.extraheader, not URL-embedded PAT
Sibling site to the clone fix: the conventions read-clone refresh ran
'git fetch --tags <https://TOKEN@host> <branch>', exposing the PAT in the
fetch argv on the orchestrator host. Mirrors the clone site's per-call
'-c http.extraheader=Authorization: Basic …' prefix + bare URL. SSH URLs
and tokenless public repos unchanged.
* [H11] release_executor clone+push via http.extraheader; delete _inject_token_into_url
* [H8] rebase_onto_base gates on clean tree like pull
* [H9] _link_commit_to_task flushes, doesn't commit out-of-band
* [M38] _pr_is_merged returns None on HTTPError; caller assumes merged
* [M39] _cherry_unmerged_entry marker grep anchored to commit-prefix
* [L1] thread actor_agent_id through update_pr_for_task
* [H8] fix rebase test mocks for clean-tree gate
H8 inserted a 'git status --porcelain' dirty-tree gate at the top of
rebase_onto_base (mirroring pull). The 3 rebase control-flow tests mocked
_run_git with a side_effect list matching the OLD call sequence (no
leading status call), so every call shifted by one and the assertions
missed. Prepend a clean-status result to each list so the gate passes
and the fetch/checkout/reset/rebase/diff/abort/push sequence aligns.
Verified: 16 passed (was 3 failed/13 passed post-H8, 16 passed pre-H8).
* [L2] push --force-with-lease instead of bare --force
* [L1] refresh stale workspace-resolution docstrings
pr_target and _workspace_for_branch still documented the actor →
assigned_to → created_by fallback chain that L1 removed from
_resolve_workspace_agent_id. Update both to the post-L1 actor →
assigned_to → None resolver (project.workspace_path as the final
fallback) so a future reader doesn't rely on a fallback that no
longer exists.
* [M37] merge_pr locks the work_session row FOR UPDATE
* [phase4] e2e smoke + CHANGELOG for 0.19.0
* [phase4] fix M37 test flake + document H8 skip
The opus whole-branch review flagged the M37 concurrency tests as
~50% flaky: both asserted caller A wins the FOR UPDATE race, but
which caller wins the lock is non-deterministic. When B won, the
'assert a_row.merged_by == a_merger' branch flipped false even
though the production code (M37) was correct — exactly one merger
recorded, audit trail intact. Assert the invariant instead: both
rows COMPLETED, both report the same merged_by, value in
{a_merger, b_merger}. Applied to both the unit test and the e2e
twin. Also documents the H8 e2e skip in the module docstring (the
report claimed it was documented there but it wasn't) and drops
the internal 'Phase 4' label from the docstring header in favor of
the public '0.19.0' version anchor.
* [H24] wait_for_ci polls through the window on non-success
* [H25,L34] release mutex orphan-sweep on start + shared redis client
* [M1] tiktok _refresh commits rotated tokens in an independent session
* [H25] drop new type:ignore in orphan-sweep test (constraint cleanup)
* [M2] feature-spotlight re-arms when exploration stale past 2x interval with no live HoM spawn
* [M6,M7] mark_seen after meaningful+project; persist since_id cursor in redis
* [M3,M5] reject() guards COMPLETED; edited_body deferred into the single-flight lock
* [M4] bound list_completed_video_tasks + ix_tasks_source_status_created index (migration 066)
* [M8,M9,L9] pass head_sha to CI gate; _run_git 30s timeout; _commits_since split maxsplit 2
* [M10,L35] dedupe dep_update by (git_url, command); fold redundant per-project queries
* [L36] gather ci_watch telemetry sweep instead of sequential iteration
* [L11] document self_heal fingerprint is stable per-signal by design
* [M11] engine-loop liveness watchdog: heartbeat + 2x-interval staleness alert
* [M21] video render loop commits per-task, not one trailing commit
* [M22] _detect_stuck_tasks skips held-CEO-source tasks
* [L6] video_renderer_client._save writes temp + atomic rename
* [phase5] e2e smoke + CHANGELOG for 0.19.0
* [M11] instrument x_mentions + roadmap engine loops with liveness heartbeats
* [phase5] fix-wave: correct e2e M11 unit-test filename + strengthen failed-cycle heartbeat assertion
* [C4] panel WS: shared /ws/system socket + long-tail retry + pong watchdog
* [H15] video-post-queue caption derived per render (mirror x-post-queue)
* [C4-fix] panel WS: discriminating long-tail tests + drop dead freeze block + evict dead shared conn on manual disconnect
Finding 1 (Critical, websocket.test.ts): the two long-tail-retry tests fired onopen between close cycles, which reset reconnectAttempts to 0 each cycle, so they passed under the pre-fix 3-attempt gate. Rewrote both to NEVER fire onopen between closes, so attempts accumulates: test 1 asserts state stays 'reconnecting' past attempt 3 (old gate would flip 'disconnected' terminal); test 2 asserts a new socket is constructed within 30000ms at attempt 7 where uncapped 5000*1.5^7 ~= 85s (old uncapped code would leave the timer unexpired). Verified both FAIL on a reverted old-shape connection.ts and PASS on the fixed code.
Finding 2 (Important, connection.ts): the 'if (raw >= cap) this.reconnectAttempts = exp' block was a no-op (exp was just read from the same field) and the unconditional increment afterwards grew the counter regardless. Deleted the dead block; kept the Math.min cap on the delay. Replaced the misleading ponytail comment with an accurate one: delay is capped, counter grows unbounded but delay is bounded.
Finding 3 (Important, use-websocket.ts): manual disconnect() tore down the shared conn for all subscribers but left the dead (manualClose=true, never reconnects) entry in _sharedSockets, so a later mount hit the reuse branch, attached a subscriber, replayed 'disconnected', and never called connect(). Added a urlRef and _sharedSockets.delete(url) in the manual disconnect callback so a later mount reopens a fresh conn.
* [H16] settings Save wired to settingsApi (persist + read back)
* [H17] tasks page passes status/team/limit to useTasks (server-side filter)
* [H18] useAgents roster re-derives on live-status change (statusEpoch in queryKey)
* [M40] useMetrics reads agent counts from useAgentStatus cache (dedupe poll)
* [H18] tighten useAgents statusEpoch comment (drop spec ref)
* [M40] drop spec ref + tighten useMetrics comment
* [M41] scorecard refetchInterval 60s -> 5min (25 req/min -> 5)
* [M42] feature-flag off-transition confirm + pending-keys Set
* [M43] X/TikTok credentials clear-behind confirm dialog
* [M44] rate-limit syncFromApi merges (keep fresher hitAt) + A2A reconnect invalidation
* [phase6] proxy.ts cookie-check comment + CHANGELOG Fixed entries
* [phase6] drop stale WS pin-attempts comment + fix tasks-page lead-in
* [H21] type DelegateRequest.estimated_complexity as Complexity (reject critical)
* [H22] type SoftBlockRequest.resolver_type as BlockerResolverType (no silent AGENT fallback)
* [H23] serialize TaskTable.documents into TaskResponse (DocRefResponse)
* [L27] delete SubstituteRequest phantom suggested_role/suggested_team fields
* [L14] Envelope.not_found defaults remediate (guide re-fetch + re-issue)
* [L28] delete unused ListResponse generic (dead code; pagination deferred)
* [H19] _delegate_static_guards allow cell_projects roots (cross-cell MegaTask)
* [M13] MegaTask confirm-batch idempotency key from session_id (SETNX guard + result sidecar)
* [M14] strip assigned_to from MegaTask drafts (no board-owned root-subtask deadlock)
* [H20] thin_routes receiver-gate add/add_all/merge (no false block on set/cache.add)
* [M16] tighten noqa code-capture to [A-Z0-9, ]+ (no false block on natural prose)
* [M45] conventions read-clone force-refetch on read (no 30s stale map window)
* [L25] conventions._resolve returns (root, sha); ORM mutated on the event loop
* [M15] open_conventions_pr force-pushes disposable scaffold branch (no silent None)
* [L24] roadmap cycle completion emits status-transition audit
* [Phase7] CHANGELOG: 15 schema/conventions/MegaTask/API fixed (H21-H23,L27,L14,L28,H19,M13,M14,H20,M16,M45,L25,M15,L24)
* [Phase7] lint gate hygiene: shorten docstring (E501), sort imports (I001), hoist AuditLogTable import (PLC0415)
* [H14] Enable the GROK provider row in _apply_grok so routing reaches the GrokCliProvider
* [M31] Route GROK active-token resolution to usage.json so live usage reflects grok agents
* [M32] Pass cache read/write tokens to calculate_cost in the usage sweep so live cost reflects Anthropic cache spend
* [M33] Park Ollama-Cloud rate limits via a marker map so a glm-5.2:cloud 429 parks instead of crash-respawning
* [M34] Sweep orphan agent_spawn_sessions at startup so crashed-run tokens roll into usage/cost summaries
* [L12] Persist revisit_resets (migration 067) so the PM-respawn breaker's revisit counter survives a restart
* [L18] Date-gate the Sonnet-5 promo revert so billing returns to list rates after 2026-08-31
* [L20] Warn when ROBOCO_GROK_RUN_LOG yields no session id instead of silently falling back to a zero-usage env id
* [phase8] CHANGELOG: LLM provider routing, usage capture, billing fixes
* [phase8] Trailing ruff format hygiene (orchestrator marker tuples, token-sweep test signatures)
* [phase8] Fix mypy: rename GROK-branch tokens var so transcript fallback stays reachable
* [M35] Add an expiring agent-token format (iat/exp) with backward-compatible verify
* [M35] Wire agent-token TTL at spawn (config + orchestrator + grok) so tokens are bounded
* [M36] Add JWT jti claim and re-mint the sliding cookie only near expiry so a stolen cookie's exp is fixed
* [M36] Redis jti revocation: read_token rejects revoked jtis and logout revokes the current jti
* [phase9] CHANGELOG: bound agent tokens + sliding-cookie re-mint window + jti revocation
* [scan-fix] mypy: type-annotate test files for make-quality gate
CI's make quality runs mypy roboco/ tests/; the scan-fix program's local
gate ran mypy roboco/ only, so test files were never type-checked. Fix all
67 errors across 23 test files with real annotations/casts/asserts/dead-code
removal — no # type: ignore / # noqa added.
* [e2e] Per-test DB isolation + dispatcher re-claim before PM complete
* [scan] Regenerate verb tables for delegate Complexity type
* [scan] Reduce 9 xenon C-ranks to B (auth, orchestrator, gateway, services)
* [scan] Restore short-circuit time.time() in verify_agent_token (security path)
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
3241 lines
108 KiB
Python
3241 lines
108 KiB
Python
"""TaskService coverage — status transitions + claim/unclaim/cancel paths.
|
|
|
|
Covers the core lifecycle methods (start, claim, unclaim variants, pause,
|
|
resume, cancel cascades, block/unblock, fail_qa with original-developer
|
|
reassignment, ceo_approve/ceo_reject, escalation chains).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
from unittest.mock import AsyncMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.db.tables import (
|
|
AgentTable,
|
|
AuditLogTable,
|
|
JournalEntryTable,
|
|
ProductTable,
|
|
ProjectTable,
|
|
TaskTable,
|
|
WorkSessionTable,
|
|
)
|
|
from roboco.events import EventType
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
from roboco.models.base import (
|
|
BlockerResolverType,
|
|
Complexity,
|
|
JournalEntryType,
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
)
|
|
from roboco.models.task import TaskCreateRequest
|
|
from roboco.models.work_session import WorkSessionStatus
|
|
from roboco.seeds.initial_data import AGENT_UUIDS
|
|
from roboco.services.base import NotFoundError
|
|
from roboco.services.task import SoftBlockInfo, TaskService, get_task_service
|
|
from sqlalchemy import Table, select
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def task_setup(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict]:
|
|
agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Dev",
|
|
slug=f"be-dev-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(agent)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="T-Proj",
|
|
slug=f"t-proj-{uuid4().hex[:8]}",
|
|
git_url="https://example.com/r.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=agent.id,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
yield {
|
|
"svc": TaskService(db_session),
|
|
"agent_id": agent.id,
|
|
"project_id": project.id,
|
|
"db": db_session,
|
|
}
|
|
|
|
|
|
def _req(setup: dict, **overrides: Any) -> TaskCreateRequest:
|
|
return TaskCreateRequest(
|
|
title=overrides.pop("title", "t"),
|
|
description=overrides.pop("description", "d"),
|
|
acceptance_criteria=overrides.pop("acceptance_criteria", ["ac"]),
|
|
team=overrides.pop("team", Team.BACKEND),
|
|
created_by=setup["agent_id"],
|
|
project_id=setup["project_id"],
|
|
task_type=overrides.pop("task_type", TaskType.CODE),
|
|
nature=overrides.pop("nature", TaskNature.TECHNICAL),
|
|
estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM),
|
|
**overrides,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_returns_none_for_missing(task_setup: dict) -> None:
|
|
svc = task_setup["svc"]
|
|
assert await svc.start(uuid4()) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_returns_none_when_invalid_status(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
assert await svc.start(task.id, agent_role="developer") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_returns_none_when_no_plan(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.plan = None
|
|
await db_session.flush()
|
|
assert await svc.start(task.id, agent_role="developer") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_with_plan_advances_to_in_progress(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.branch_name = "feature/backend/AAAAAAA"
|
|
task.plan = {"text": "step 1"}
|
|
await db_session.flush()
|
|
started = await svc.start(
|
|
task.id, agent_id=task_setup["agent_id"], agent_role="developer"
|
|
)
|
|
assert started is not None
|
|
assert started.status == TaskStatus.IN_PROGRESS
|
|
assert started.started_at is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_batch_umbrella_advances_without_branch(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A MegaTask umbrella (batch_id, no project/product, no branch) is branchless
|
|
coordination — the claimed->in_progress branch gate must be skipped for it so
|
|
it can reach in_progress and delegate, exactly like a product fan-out root."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.project_id = None
|
|
task.product_id = None
|
|
task.batch_id = uuid4()
|
|
task.parent_task_id = None
|
|
task.branch_name = None
|
|
task.plan = {"text": "sequence the waves"}
|
|
await db_session.flush()
|
|
started = await svc.start(
|
|
task.id, agent_id=task_setup["agent_id"], agent_role="developer"
|
|
)
|
|
assert started is not None
|
|
assert started.status == TaskStatus.IN_PROGRESS
|
|
assert started.branch_name is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_returns_none_when_ownership_fails(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Non-assignee agent cannot start the task."""
|
|
svc = task_setup["svc"]
|
|
other = AgentTable(
|
|
id=uuid4(),
|
|
name="Other",
|
|
slug=f"other-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.assigned_to = other.id
|
|
task.plan = {"text": "p"}
|
|
await db_session.flush()
|
|
out = await svc.start(
|
|
task.id, agent_id=task_setup["agent_id"], agent_role="developer"
|
|
)
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_paused_task_resumes_in_progress(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.PAUSED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.plan = {"text": "p"}
|
|
await db_session.flush()
|
|
started = await svc.start(task.id, agent_role="developer")
|
|
assert started is not None
|
|
assert started.status == TaskStatus.IN_PROGRESS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# unclaim_for_reaper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclaim_for_reaper_resets_claim_but_keeps_owner(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.active_claimant_id = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
await svc.unclaim_for_reaper(task.id)
|
|
refreshed = await svc.get(task.id)
|
|
assert refreshed is not None
|
|
assert refreshed.status == TaskStatus.PENDING
|
|
# Ownership is preserved so the same agent resumes the task once it
|
|
# re-dispatches — the task must never land in an ownerless pending limbo.
|
|
assert refreshed.assigned_to == task_setup["agent_id"]
|
|
assert refreshed.claimed_by == task_setup["agent_id"]
|
|
# The live claim is released so the reaper/dispatcher can re-spawn cleanly.
|
|
assert refreshed.active_claimant_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclaim_for_reaper_skips_when_status_already_pending(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
# Should not raise — branch returns immediately.
|
|
await svc.unclaim_for_reaper(task.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_dependency_blocked_claim_keeps_owner(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Dependency-release returns to pending without orphaning the owner.
|
|
|
|
Shares ``_force_unclaim_to_pending`` with the reaper, so it must give the
|
|
same guarantee: the same agent resumes once the upstream dependency lands.
|
|
The work-in-progress branch is forgotten so the re-claim cuts fresh off the
|
|
(now-updated) integration tip.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.claimed_by = task_setup["agent_id"]
|
|
task.active_claimant_id = task_setup["agent_id"]
|
|
task.branch_name = "feature/backend/ABC12345"
|
|
await db_session.flush()
|
|
await svc.release_dependency_blocked_claim(task.id)
|
|
refreshed = await svc.get(task.id)
|
|
assert refreshed is not None
|
|
assert refreshed.status == TaskStatus.PENDING
|
|
assert refreshed.assigned_to == task_setup["agent_id"]
|
|
assert refreshed.claimed_by == task_setup["agent_id"]
|
|
assert refreshed.active_claimant_id is None
|
|
assert refreshed.branch_name is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# unclaim_for_agent — all error paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclaim_for_agent_returns_none_when_wrong_assignee(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
other = AgentTable(
|
|
id=uuid4(),
|
|
name="Other",
|
|
slug=f"other-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.CLAIMED
|
|
task.assigned_to = other.id
|
|
await db_session.flush()
|
|
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclaim_for_agent_releases_pending_assignment(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""#176: an agent assigned a pending task it never claimed must be
|
|
able to unclaim it (escape the pending-assigned trap). The row stays
|
|
pending; only the assignment is released so the dispatcher can
|
|
reassign it instead of orphaning the task + looping the agent."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
# status is PENDING, never claimed — the smoke-17 trap scenario.
|
|
out = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
|
|
assert out is not None
|
|
assert out.status == TaskStatus.PENDING
|
|
assert out.assigned_to is None
|
|
assert out.active_claimant_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclaim_for_agent_returns_none_when_paused(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A non-pending/claimed/in_progress status (e.g. paused — which has
|
|
`resume`, not `unclaim`) is still not unclaimable."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.PAUSED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resume_for_agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_for_agent_returns_none_when_wrong_assignee(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
other = AgentTable(
|
|
id=uuid4(),
|
|
name="Other",
|
|
slug=f"other-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.PAUSED
|
|
task.assigned_to = other.id
|
|
await db_session.flush()
|
|
assert await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_for_agent_returns_none_when_not_paused(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
assert await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_for_agent_advances_to_in_progress(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.PAUSED
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
out = await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"])
|
|
assert out is not None
|
|
assert out.status == TaskStatus.IN_PROGRESS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# block / unblock with task-dependency blocker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_returns_none_for_missing(task_setup: dict) -> None:
|
|
svc = task_setup["svc"]
|
|
blocker = await svc.create(_req(task_setup))
|
|
assert await svc.block(uuid4(), blocker_task_id=blocker.id) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_adds_to_dependencies(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
blocker = await svc.create(_req(task_setup))
|
|
blocked = await svc.block(task.id, blocker_task_id=blocker.id)
|
|
assert blocked is not None
|
|
assert blocked.status == TaskStatus.BLOCKED
|
|
assert blocker.id in blocked.dependency_ids
|
|
assert blocked.blocker_resolver_type == BlockerResolverType.AGENT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_does_not_duplicate_existing_dep(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
blocker = await svc.create(_req(task_setup))
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.dependency_ids = [blocker.id]
|
|
await db_session.flush()
|
|
blocked = await svc.block(task.id, blocker_task_id=blocker.id)
|
|
assert blocked is not None
|
|
# Still single occurrence
|
|
assert blocked.dependency_ids.count(blocker.id) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# soft_block — full happy path including HUMAN resolver type
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_block_with_human_resolver(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
blocked = await svc.soft_block(
|
|
task.id,
|
|
SoftBlockInfo(
|
|
reason="HITL needed",
|
|
blocker_type="question",
|
|
what_needed="answer",
|
|
resolver_type=BlockerResolverType.HUMAN,
|
|
),
|
|
)
|
|
assert blocked is not None
|
|
assert blocked.status == TaskStatus.BLOCKED
|
|
assert blocked.blocker_resolver_type == BlockerResolverType.HUMAN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_block_returns_none_when_not_in_progress(
|
|
task_setup: dict,
|
|
) -> None:
|
|
"""soft_block requires IN_PROGRESS — PENDING task fails the gate."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
out = await svc.soft_block(
|
|
task.id,
|
|
SoftBlockInfo(reason="x", blocker_type="ext", what_needed="y"),
|
|
)
|
|
assert out is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# unblock — when not BLOCKED returns None
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_returns_none_when_not_blocked(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
await db_session.flush()
|
|
assert await svc.unblock(task.id) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pause/resume happy paths verifying side-effects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_in_progress_works(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
await db_session.flush()
|
|
out = await svc.pause(task.id)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.PAUSED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_paused_works(task_setup: dict, db_session: AsyncSession) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.PAUSED
|
|
await db_session.flush()
|
|
out = await svc.resume(task.id)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.IN_PROGRESS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fail_qa: original developer reassignment & no-original-developer fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_qa_reassigns_to_original_developer(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
dev_id = task_setup["agent_id"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.orchestration_markers = {"original_developer": str(dev_id)}
|
|
await db_session.flush()
|
|
failed = await svc.fail_qa(task.id, notes="missing tests")
|
|
assert failed is not None
|
|
assert failed.status == TaskStatus.NEEDS_REVISION
|
|
assert failed.assigned_to == dev_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_qa_increments_revision_count(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Each QA bounce to needs_revision increments the O(1) rework counter."""
|
|
svc = task_setup["svc"]
|
|
dev_id = task_setup["agent_id"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.orchestration_markers = {"original_developer": str(dev_id)}
|
|
await db_session.flush()
|
|
assert task.revision_count == 0
|
|
|
|
failed = await svc.fail_qa(task.id, notes="missing tests")
|
|
assert failed is not None
|
|
assert failed.revision_count == 1
|
|
count_after_first = failed.revision_count
|
|
|
|
# A second QA cycle bumps it again (once per transition into needs_revision).
|
|
failed.status = TaskStatus.AWAITING_QA
|
|
await db_session.flush()
|
|
again = await svc.fail_qa(task.id, notes="still missing")
|
|
assert again is not None
|
|
assert again.revision_count == count_after_first + 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_qa_with_no_original_dev_unassigns(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
# No quick_context — extract_original_developer returns None
|
|
await db_session.flush()
|
|
failed = await svc.fail_qa(task.id, notes="needs more")
|
|
assert failed is not None
|
|
assert failed.assigned_to is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_qa_routes_to_dev_via_work_session_when_marker_missing(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A dev task in needs_revision with a MISSING original_developer marker
|
|
routes back to the developer who worked it (resolved from the work
|
|
session), NOT to the pool.
|
|
|
|
Unassigning sends the task to the pool where a cell PM (PMs can re-claim
|
|
needs_revision) grabs it — the live 2026-06-27 "needs revision on a dev
|
|
task sent to the cell PM" bug. The marker is the fast path; the work
|
|
session is the load-bearing fallback (the marker is unreliable in
|
|
practice). The fallback also self-heals the marker so a subsequent
|
|
re-fail takes the fast path.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
dev_id = task_setup["agent_id"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.branch_name = "feature/backend/abc"
|
|
await db_session.flush()
|
|
|
|
# The dev worked the task (a work session exists) but the marker was never
|
|
# set — e.g. the task was rerouted before submit_for_qa ran, or the marker
|
|
# failed to persist (live observation). This is the "OG DEV NEVER
|
|
# PERSISTED" scenario.
|
|
ws = WorkSessionTable(
|
|
id=uuid4(),
|
|
project_id=task_setup["project_id"],
|
|
task_id=task.id,
|
|
agent_id=dev_id,
|
|
branch_name="feature/backend/abc",
|
|
base_branch="main",
|
|
target_branch="main",
|
|
status=WorkSessionStatus.ACTIVE,
|
|
)
|
|
db_session.add(ws)
|
|
|
|
# A separate QA agent claims and fails the task.
|
|
qa = AgentTable(
|
|
id=uuid4(),
|
|
name="QA",
|
|
slug=f"be-qa-{uuid4().hex[:8]}",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(qa)
|
|
await db_session.flush()
|
|
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.assigned_to = qa.id
|
|
task.claimed_by = qa.id
|
|
# No orchestration_markers — extract_original_developer returns None.
|
|
await db_session.flush()
|
|
|
|
failed = await svc.fail_qa(task.id, notes="needs more")
|
|
assert failed is not None
|
|
assert failed.status == TaskStatus.NEEDS_REVISION
|
|
# Routed back to the dev — NOT unassigned (pool, where a cell PM would
|
|
# claim it) and NOT left on the QA.
|
|
assert failed.assigned_to == dev_id
|
|
assert failed.claimed_by == dev_id
|
|
# Self-healed: the marker is now stamped so the next fail_qa uses the
|
|
# fast path and the QA-review index attributes the work correctly.
|
|
assert markers.get_original_developer(failed) == str(dev_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_subtask_round_trips_collision_surfaces_and_deps(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""create_subtask forwards intends_to_touch / adds_migration /
|
|
touches_shared / sequence / dependency_ids onto the created subtask.
|
|
|
|
Previously these were dropped inside create_subtask's `prepared`
|
|
TaskCreateRequest, so a dev task delegated with a collision surface or an
|
|
explicit dependency lost it before persistence — the root cause of
|
|
dev-task dependency_ids always being [] (the live 2026-06-27 out-of-order
|
|
break). The base ``create`` persists them (task.py:878-884); this test
|
|
locks the forwarding through create_subtask.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
await db_session.flush()
|
|
dep_id = uuid4()
|
|
sub = await svc.create_subtask(
|
|
TaskCreateRequest(
|
|
title="child",
|
|
description="child description with enough length",
|
|
acceptance_criteria=["ac1"],
|
|
team=Team.BACKEND,
|
|
created_by=task_setup["agent_id"],
|
|
project_id=task_setup["project_id"],
|
|
parent_task_id=parent.id,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
sequence=3,
|
|
dependency_ids=[dep_id],
|
|
intends_to_touch=["roboco/services/foo.py"],
|
|
adds_migration=True,
|
|
touches_shared=True,
|
|
)
|
|
)
|
|
assert sub.intends_to_touch == ["roboco/services/foo.py"]
|
|
assert sub.adds_migration is True
|
|
assert sub.touches_shared is True
|
|
expected_sequence = 3
|
|
assert sub.sequence == expected_sequence
|
|
assert sub.dependency_ids == [dep_id]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wire_sibling_collision_dag_serializes_overlapping_dev_tasks(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""wire_sibling_collision_dag runs the collision analyzer over a parent's
|
|
surfaced dev-task siblings and wires dependency_ids so a later dev task
|
|
whose surface overlaps an earlier one stays PENDING until it completes.
|
|
|
|
T1 (a.py) and T2 (b.py) are disjoint -> parallel (no edge). T3 (a.py)
|
|
overlaps T1 -> T3 depends-on T1. The explicit `depends_on` override on T3
|
|
(forwarded through create_subtask in S1) is also present."""
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
await db_session.flush()
|
|
|
|
async def _dev(seq: int, surface: list[str]) -> Any:
|
|
t = await svc.create_subtask(
|
|
TaskCreateRequest(
|
|
title=f"dev-{seq}",
|
|
description=f"dev task {seq} description long enough",
|
|
acceptance_criteria=["ac"],
|
|
team=Team.BACKEND,
|
|
created_by=task_setup["agent_id"],
|
|
project_id=task_setup["project_id"],
|
|
parent_task_id=parent.id,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
sequence=seq,
|
|
intends_to_touch=surface,
|
|
)
|
|
)
|
|
await svc.set_sequence(t.id, seq)
|
|
return t
|
|
|
|
t1 = await _dev(0, ["roboco/api/a.py"])
|
|
t2 = await _dev(1, ["roboco/api/b.py"])
|
|
t3 = await _dev(2, ["roboco/api/a.py"])
|
|
|
|
await svc.wire_sibling_collision_dag(parent.id)
|
|
|
|
# Reload to read the wired dependency_ids.
|
|
r1 = await svc.get(t1.id)
|
|
r2 = await svc.get(t2.id)
|
|
r3 = await svc.get(t3.id)
|
|
assert r1 is not None and r2 is not None and r3 is not None
|
|
# T1 and T2 are disjoint -> parallel (no collision edge between them).
|
|
assert r1.dependency_ids == []
|
|
assert r2.dependency_ids == []
|
|
# T3 overlaps T1 (same file, same repo) -> T3 depends-on T1.
|
|
assert t1.id in r3.dependency_ids
|
|
# T2 does NOT collide with T3 (disjoint file) -> no T2->T3 edge.
|
|
assert t2.id not in r3.dependency_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wire_sibling_collision_dag_chains_undeclared_same_assignee_lane(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Undeclared-surface fallback: two dev siblings with NO collision surface
|
|
on the same assignee + same repo are chained by sequence so the later one
|
|
waits for the earlier — the live out-of-order start (a dev with an unmerged
|
|
earlier task starting the next one) is prevented at wiring time. Cross-dev
|
|
siblings stay parallel (the lane is same-assignee scoped)."""
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
await db_session.flush()
|
|
other_dev = AgentTable(
|
|
id=uuid4(),
|
|
name="Dev2",
|
|
slug=f"be-dev-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(other_dev)
|
|
await db_session.flush()
|
|
|
|
async def _dev(seq: int, assignee: UUID) -> Any:
|
|
t = await svc.create_subtask(
|
|
TaskCreateRequest(
|
|
title=f"dev-{seq}",
|
|
description=f"dev task {seq} description long enough",
|
|
acceptance_criteria=["ac"],
|
|
team=Team.BACKEND,
|
|
created_by=task_setup["agent_id"],
|
|
project_id=task_setup["project_id"],
|
|
parent_task_id=parent.id,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
sequence=seq,
|
|
assigned_to=assignee,
|
|
)
|
|
)
|
|
await svc.set_sequence(t.id, seq)
|
|
return t
|
|
|
|
# Same assignee, no declared surface -> fallback chains seq-1 behind seq-0.
|
|
a = await _dev(0, task_setup["agent_id"])
|
|
b = await _dev(1, task_setup["agent_id"])
|
|
# Different assignee, no declared surface -> parallel (no fallback edge).
|
|
c = await _dev(2, cast("UUID", other_dev.id))
|
|
|
|
await svc.wire_sibling_collision_dag(parent.id)
|
|
|
|
ra = await svc.get(a.id)
|
|
rb = await svc.get(b.id)
|
|
rc = await svc.get(c.id)
|
|
assert ra is not None and rb is not None and rc is not None
|
|
# Earlier sibling leads the lane (no incoming edge).
|
|
assert ra.dependency_ids == []
|
|
# Later same-assignee sibling waits on the earlier one.
|
|
assert a.id in rb.dependency_ids
|
|
# Cross-dev sibling is not chained onto the first dev's lane.
|
|
assert a.id not in rc.dependency_ids
|
|
assert b.id not in rc.dependency_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wire_cell_task_wave_chain_chains_to_predecessor_cell_tasks(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Kind 2: a cell-task under root-subtask R1 depends on every cell-task
|
|
under R0, where R1 depends-on R0 (the kind-1 wave-chain edge on R1).
|
|
|
|
A root-subtask may fan to several cell-tasks (different cells) — both of
|
|
R0's cell-tasks are wired onto R1's cell-task so its branch carries the
|
|
whole previous wave's merged cell work. Idempotent (add_dependency dedupes).
|
|
"""
|
|
svc = task_setup["svc"]
|
|
# Two root-subtasks; R1 depends-on R0 (the kind-1 edge lives on R1).
|
|
r0 = await svc.create(
|
|
_req(
|
|
task_setup,
|
|
title="r0",
|
|
team=Team.MAIN_PM,
|
|
task_type=TaskType.PLANNING,
|
|
nature=TaskNature.TECHNICAL,
|
|
)
|
|
)
|
|
r1 = await svc.create(
|
|
_req(
|
|
task_setup,
|
|
title="r1",
|
|
team=Team.MAIN_PM,
|
|
task_type=TaskType.PLANNING,
|
|
nature=TaskNature.TECHNICAL,
|
|
dependency_ids=[UUID(str(r0.id))],
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
async def _cell(parent_id: UUID, team: Team) -> Any:
|
|
return await svc.create_subtask(
|
|
TaskCreateRequest(
|
|
title=f"ct-{team.value}",
|
|
description="cell task description long enough",
|
|
acceptance_criteria=["ac"],
|
|
team=team,
|
|
created_by=task_setup["agent_id"],
|
|
project_id=task_setup["project_id"],
|
|
parent_task_id=parent_id,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
)
|
|
)
|
|
|
|
# R0 fans to two cell-tasks (backend + frontend — the cross-cell fanout the
|
|
# CEO confirmed a root-subtask may carry).
|
|
ct0a = await _cell(UUID(str(r0.id)), Team.BACKEND)
|
|
ct0b = await _cell(UUID(str(r0.id)), Team.FRONTEND)
|
|
# R1's cell-task.
|
|
ct1 = await _cell(UUID(str(r1.id)), Team.BACKEND)
|
|
|
|
await svc.wire_cell_task_wave_chain(ct1.id)
|
|
# Idempotent: a second run adds no duplicates.
|
|
await svc.wire_cell_task_wave_chain(ct1.id)
|
|
|
|
r1ct = await svc.get(ct1.id)
|
|
assert r1ct is not None
|
|
# ct1 depends on BOTH of R0's cell-tasks (the whole previous wave's set).
|
|
assert UUID(str(ct0a.id)) in r1ct.dependency_ids
|
|
assert UUID(str(ct0b.id)) in r1ct.dependency_ids
|
|
# Idempotency: each predecessor appears once.
|
|
assert r1ct.dependency_ids.count(UUID(str(ct0a.id))) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wire_by_osmosis_edge_first_dev_task_depends_on_prev_wave_tail(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Kind 4: the first dev task (sequence 0) of a cell-task under R1 depends
|
|
on the tail (highest-sequence) dev task of R0's cell-task. A non-first dev
|
|
task (sequence > 0) gets no by-osmosis edge (it inherits the tail via the
|
|
kind-3 collision DAG or shares the merged base)."""
|
|
svc = task_setup["svc"]
|
|
r0 = await svc.create(
|
|
_req(
|
|
task_setup,
|
|
title="r0",
|
|
team=Team.MAIN_PM,
|
|
task_type=TaskType.PLANNING,
|
|
nature=TaskNature.TECHNICAL,
|
|
)
|
|
)
|
|
r1 = await svc.create(
|
|
_req(
|
|
task_setup,
|
|
title="r1",
|
|
team=Team.MAIN_PM,
|
|
task_type=TaskType.PLANNING,
|
|
nature=TaskNature.TECHNICAL,
|
|
dependency_ids=[UUID(str(r0.id))],
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
async def _sub(parent_id: UUID, seq: int) -> Any:
|
|
t = await svc.create_subtask(
|
|
TaskCreateRequest(
|
|
title=f"t{seq}",
|
|
description=f"subtask {seq} description long enough",
|
|
acceptance_criteria=["ac"],
|
|
team=Team.BACKEND,
|
|
created_by=task_setup["agent_id"],
|
|
project_id=task_setup["project_id"],
|
|
parent_task_id=parent_id,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.MEDIUM,
|
|
sequence=seq,
|
|
)
|
|
)
|
|
await svc.set_sequence(t.id, seq)
|
|
return t
|
|
|
|
# R0's cell-task with two dev tasks; tail = sequence 1.
|
|
ct0 = await _sub(UUID(str(r0.id)), 0)
|
|
d0a = await _sub(UUID(str(ct0.id)), 0)
|
|
d0b = await _sub(UUID(str(ct0.id)), 1) # tail
|
|
# R1's cell-task with the first dev task (sequence 0) + a later one.
|
|
ct1 = await _sub(UUID(str(r1.id)), 0)
|
|
first = await _sub(UUID(str(ct1.id)), 0)
|
|
second = await _sub(UUID(str(ct1.id)), 1)
|
|
|
|
await svc.wire_by_osmosis_edge(first.id)
|
|
await svc.wire_by_osmosis_edge(second.id)
|
|
|
|
rf = await svc.get(first.id)
|
|
rs = await svc.get(second.id)
|
|
assert rf is not None and rs is not None
|
|
# The first dev task carries the by-osmosis edge to R0's cell-task's TAIL
|
|
# (d0b, sequence 1) — not the non-tail d0a.
|
|
assert UUID(str(d0b.id)) in rf.dependency_ids
|
|
assert UUID(str(d0a.id)) not in rf.dependency_ids
|
|
# The second dev task (sequence 1) gets NO by-osmosis edge.
|
|
assert UUID(str(d0b.id)) not in rs.dependency_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_qa_work_session_fallback_excludes_qa_session(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""The work-session fallback must not hand the task back to the QA.
|
|
|
|
If the only developer work session were the QA's (it isn't — QA sessions
|
|
are skipped at creation — but defensively the exclude filter must keep a
|
|
QA-attributed session from being misread as the revision dev), the
|
|
fallback would loop the task back to the reviewer. The exclude filter
|
|
(qa_agent_id) guarantees only a real developer is resolved.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
dev_id = task_setup["agent_id"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.branch_name = "feature/backend/abc"
|
|
await db_session.flush()
|
|
|
|
qa = AgentTable(
|
|
id=uuid4(),
|
|
name="QA",
|
|
slug=f"be-qa-{uuid4().hex[:8]}",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(qa)
|
|
await db_session.flush()
|
|
|
|
# A QA-attributed work session (older, abandoned) and a dev-attributed one
|
|
# (newer, active). Only one ACTIVE session may exist per task
|
|
# (``uq_work_sessions_one_active_per_task``), so the QA session is
|
|
# ABANDONED — realistic for a stale QA review session and still in the
|
|
# fallback query's result set (the query filters by task_id + agent_id,
|
|
# not by status). The exclude filter (``agent_id != qa_agent_id``) is
|
|
# what must keep the QA session from being misread as the revision dev.
|
|
qa_ws = WorkSessionTable(
|
|
id=uuid4(),
|
|
project_id=task_setup["project_id"],
|
|
task_id=task.id,
|
|
agent_id=qa.id,
|
|
branch_name="feature/backend/abc",
|
|
base_branch="main",
|
|
target_branch="main",
|
|
status=WorkSessionStatus.ABANDONED,
|
|
)
|
|
dev_ws = WorkSessionTable(
|
|
id=uuid4(),
|
|
project_id=task_setup["project_id"],
|
|
task_id=task.id,
|
|
agent_id=dev_id,
|
|
branch_name="feature/backend/abc",
|
|
base_branch="main",
|
|
target_branch="main",
|
|
status=WorkSessionStatus.ACTIVE,
|
|
)
|
|
db_session.add_all([qa_ws, dev_ws])
|
|
await db_session.flush()
|
|
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.assigned_to = qa.id
|
|
task.claimed_by = qa.id
|
|
await db_session.flush()
|
|
|
|
failed = await svc.fail_qa(task.id, notes="needs more")
|
|
assert failed is not None
|
|
# Resolved to the dev, NOT the QA — the exclude filter did its job.
|
|
assert failed.assigned_to == dev_id
|
|
assert failed.assigned_to != qa.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ceo_approve / ceo_reject — happy paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_approve_returns_none_when_wrong_status(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
assert await svc.ceo_approve(task.id) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_approve_marks_completed(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
|
task.pr_number = 1
|
|
task.pr_url = "u"
|
|
task.docs_complete = True
|
|
task.pr_created = True
|
|
await db_session.flush()
|
|
approved = await svc.ceo_approve(task.id, notes="approved")
|
|
assert approved is not None
|
|
assert approved.status == TaskStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_reject_returns_none_when_wrong_status(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
assert await svc.ceo_reject(task.id, reason="not aligned") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_reject_reassigns_to_original_dev(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
dev_id = task_setup["agent_id"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
|
task.orchestration_markers = {"original_developer": str(dev_id)}
|
|
await db_session.flush()
|
|
rejected = await svc.ceo_reject(task.id, reason="re-do auth flow")
|
|
assert rejected is not None
|
|
assert rejected.status == TaskStatus.NEEDS_REVISION
|
|
assert rejected.assigned_to == dev_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_reject_clears_assignment_when_no_original_dev(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
|
task.assigned_to = task_setup["agent_id"]
|
|
# No quick_context — original_dev resolves None
|
|
await db_session.flush()
|
|
rejected = await svc.ceo_reject(task.id, reason="redo")
|
|
assert rejected is not None
|
|
assert rejected.assigned_to is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_reject_routes_coordination_task_to_main_pm(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A rejected coordination/integration root goes to the Main PM to delegate,
|
|
not back to a developer."""
|
|
svc = task_setup["svc"]
|
|
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
|
|
if await db_session.get(AgentTable, main_pm_id) is None:
|
|
db_session.add(
|
|
AgentTable(
|
|
id=main_pm_id,
|
|
name="Main PM",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
# The CEO rejects the task; ceo_reject emits an audit row keyed to the CEO
|
|
# agent, so the CEO row must exist (fk_audit_log_agent_id_agents).
|
|
ceo_id = UUID(AGENT_UUIDS["ceo"])
|
|
if await db_session.get(AgentTable, ceo_id) is None:
|
|
db_session.add(
|
|
AgentTable(
|
|
id=ceo_id,
|
|
name="CEO",
|
|
slug="ceo",
|
|
role=AgentRole.CEO,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="ceo",
|
|
capabilities=[],
|
|
permissions=[],
|
|
metrics={},
|
|
)
|
|
)
|
|
product = ProductTable(
|
|
name="P", slug=f"p-{uuid4().hex[:8]}", created_by=task_setup["agent_id"]
|
|
)
|
|
db_session.add(product)
|
|
await db_session.flush()
|
|
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
|
task.project_id = None # coordination root: no project, has product
|
|
task.product_id = product.id
|
|
await db_session.flush()
|
|
|
|
rejected = await svc.ceo_reject(task.id, reason="redo the API contract")
|
|
assert rejected is not None
|
|
# A coordination root goes to PENDING (the Main PM's claim source), NOT
|
|
# needs_revision — that status is developer-claim-only and would deadlock
|
|
# the Main PM, which owns the root and must re-plan/re-delegate.
|
|
assert rejected.status == TaskStatus.PENDING
|
|
assert rejected.team == Team.MAIN_PM
|
|
assert rejected.assigned_to == main_pm_id
|
|
assert rejected.claimed_by is None
|
|
|
|
# Regression (metrics-granularity Phase 3): the coordination-root reject
|
|
# routes through admin_set_status, so it MUST still emit a CEO-attributed
|
|
# audit row transitioning OUT of awaiting_ceo_approval — the signal the CEO
|
|
# scorecard pairs for approval latency + counts as a god-mode action. If a
|
|
# future refactor drops the audit (the old gap), this fails.
|
|
audit_rows = (
|
|
(
|
|
await db_session.execute(
|
|
select(AuditLogTable).where(AuditLogTable.target_id == task.id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
ceo_rows = [
|
|
a
|
|
for a in audit_rows
|
|
if (a.details or {}).get("agent_role") == "ceo"
|
|
and (a.details or {}).get("from_status") == "awaiting_ceo_approval"
|
|
]
|
|
assert ceo_rows, "coordination ceo_reject must emit a ceo-attributed audit row"
|
|
assert ceo_rows[0].details.get("to_status") == "pending"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_reject_routes_batch_umbrella_to_main_pm(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A rejected MegaTask umbrella (batch_id, top-level, no project/product) is a
|
|
branchless coordination root too — it routes to the Main PM to re-plan, never
|
|
to a developer (needs_revision would deadlock the Main PM that owns it)."""
|
|
svc = task_setup["svc"]
|
|
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
|
|
if await db_session.get(AgentTable, main_pm_id) is None:
|
|
db_session.add(
|
|
AgentTable(
|
|
id=main_pm_id,
|
|
name="Main PM",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
# The CEO rejects the umbrella; ceo_reject emits an audit row keyed to the
|
|
# CEO agent, so the CEO row must exist (fk_audit_log_agent_id_agents).
|
|
ceo_id = UUID(AGENT_UUIDS["ceo"])
|
|
if await db_session.get(AgentTable, ceo_id) is None:
|
|
db_session.add(
|
|
AgentTable(
|
|
id=ceo_id,
|
|
name="CEO",
|
|
slug="ceo",
|
|
role=AgentRole.CEO,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="ceo",
|
|
capabilities=[],
|
|
permissions=[],
|
|
metrics={},
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
|
task.project_id = None # umbrella: no project, no product — carries a batch_id
|
|
task.product_id = None
|
|
task.batch_id = uuid4()
|
|
task.parent_task_id = None
|
|
await db_session.flush()
|
|
|
|
rejected = await svc.ceo_reject(task.id, reason="re-sequence the waves")
|
|
assert rejected is not None
|
|
assert rejected.status == TaskStatus.PENDING
|
|
assert rejected.team == Team.MAIN_PM
|
|
assert rejected.assigned_to == main_pm_id
|
|
assert rejected.claimed_by is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ceo_reject_writes_handoff_journal(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""The CEO's reason is recorded as a DECISION_LOG journal entry on the task —
|
|
the channel that actually reaches the reworker (quick_context does not)."""
|
|
svc = task_setup["svc"]
|
|
ceo_id = UUID(AGENT_UUIDS["ceo"])
|
|
if await db_session.get(AgentTable, ceo_id) is None:
|
|
db_session.add(
|
|
AgentTable(
|
|
id=ceo_id,
|
|
name="CEO",
|
|
slug="ceo",
|
|
role=AgentRole.CEO,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="ceo",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_CEO_APPROVAL
|
|
task.orchestration_markers = {"original_developer": str(task_setup["agent_id"])}
|
|
await db_session.flush()
|
|
|
|
reason = "AC9/AC10 totals must include cache tokens"
|
|
rejected = await svc.ceo_reject(task.id, reason=reason)
|
|
assert rejected is not None
|
|
|
|
entries = (
|
|
(
|
|
await db_session.execute(
|
|
select(JournalEntryTable).where(
|
|
JournalEntryTable.task_id == task.id,
|
|
JournalEntryTable.type == JournalEntryType.DECISION_LOG,
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert any(reason in (e.content or "") for e in entries)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# escalate_to_ceo - all error branches
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_to_ceo_returns_none_when_wrong_status(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
out = await svc.escalate_to_ceo(task.id, agent_role="cell_pm", notes="x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_to_ceo_returns_none_for_subtask(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
sub = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
sub.status = TaskStatus.AWAITING_PM_REVIEW
|
|
sub.pr_number = 1
|
|
await db_session.flush()
|
|
out = await svc.escalate_to_ceo(sub.id, agent_role="cell_pm", notes="x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_to_ceo_allows_batch_root_subtask(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A MegaTask root-subtask IS parented (the umbrella) yet carries its own
|
|
project/branch/PR and behaves as a root for git/CEO purposes — unlike a
|
|
plain subtask, it must NOT be refused by the parent-task-only check."""
|
|
svc = task_setup["svc"]
|
|
umbrella = await svc.create(_req(task_setup))
|
|
sub = await svc.create(_req(task_setup, parent_task_id=umbrella.id))
|
|
sub.status = TaskStatus.AWAITING_PM_REVIEW
|
|
sub.batch_id = uuid4()
|
|
sub.pr_number = 7
|
|
await db_session.flush()
|
|
escalated = await svc.escalate_to_ceo(
|
|
sub.id, agent_role="main_pm", notes="root-subtask ready for CEO sign-off"
|
|
)
|
|
assert escalated is not None
|
|
assert escalated.status == TaskStatus.AWAITING_CEO_APPROVAL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_to_ceo_returns_none_when_no_pr(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = None
|
|
await db_session.flush()
|
|
out = await svc.escalate_to_ceo(task.id, agent_role="main_pm", notes="x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_to_ceo_waives_pr_for_batch_umbrella(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A MegaTask umbrella is branchless and assembles no PR — escalate_to_ceo
|
|
must NOT block it on a missing pr_number, or umbrella completion crashes."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.project_id = None # umbrella: branchless, no repo, no PR
|
|
task.product_id = None
|
|
task.batch_id = uuid4()
|
|
task.parent_task_id = None
|
|
task.pr_number = None
|
|
await db_session.flush()
|
|
escalated = await svc.escalate_to_ceo(
|
|
task.id, agent_role="main_pm", notes="MegaTask ready for CEO sign-off"
|
|
)
|
|
assert escalated is not None
|
|
assert escalated.status == TaskStatus.AWAITING_CEO_APPROVAL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_to_ceo_advances_status_with_notes(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.pr_number = 42
|
|
task.pr_url = "https://example.com/pr/42"
|
|
task.pr_created = True
|
|
task.docs_complete = True
|
|
await db_session.flush()
|
|
escalated = await svc.escalate_to_ceo(
|
|
task.id, agent_role="main_pm", notes="needs CEO review for breaking change"
|
|
)
|
|
assert escalated is not None
|
|
assert escalated.status == TaskStatus.AWAITING_CEO_APPROVAL
|
|
# The escalation note is a structured marker now, not quick_context soup.
|
|
assert (
|
|
markers.get_transition_note(escalated, "escalate_to_ceo")
|
|
== "needs CEO review for breaking change"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cancel + cascade
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_skips_already_terminal_descendants(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Descendants already in COMPLETED/CANCELLED are skipped, not re-cancelled."""
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
completed_child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
completed_child.status = TaskStatus.COMPLETED
|
|
pending_child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
await db_session.flush()
|
|
out = await svc.cancel(parent.id, agent_role="cell_pm")
|
|
assert out is not None
|
|
refreshed_completed = await svc.get(completed_child.id)
|
|
assert refreshed_completed is not None
|
|
assert refreshed_completed.status == TaskStatus.COMPLETED
|
|
refreshed_pending = await svc.get(pending_child.id)
|
|
assert refreshed_pending is not None
|
|
assert refreshed_pending.status == TaskStatus.CANCELLED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_returns_task_with_cancellation_note_appended(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.dev_notes = "earlier note"
|
|
out = await svc.cancel(task.id, cancellation_note="duplicate")
|
|
assert out is not None
|
|
assert "duplicate" in (out.dev_notes or "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _unblock_dependents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_dependents_clears_dep_id(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Completing a blocker unblocks its dependents."""
|
|
svc = task_setup["svc"]
|
|
blocker = await svc.create(_req(task_setup))
|
|
dependent = await svc.create(_req(task_setup))
|
|
dependent.status = TaskStatus.BLOCKED
|
|
dependent.dependency_ids = [blocker.id]
|
|
await db_session.flush()
|
|
await svc._unblock_dependents(blocker.id)
|
|
refreshed = await svc.get(dependent.id)
|
|
assert refreshed is not None
|
|
assert blocker.id not in refreshed.dependency_ids
|
|
# The cleared dependency is remembered so the unblock briefing can surface it.
|
|
assert blocker.id in refreshed.completed_dependency_ids
|
|
assert refreshed.status == TaskStatus.IN_PROGRESS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_dependents_keeps_blocked_when_other_deps_remain(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
blocker = await svc.create(_req(task_setup))
|
|
other_blocker = await svc.create(_req(task_setup))
|
|
dependent = await svc.create(_req(task_setup))
|
|
dependent.status = TaskStatus.BLOCKED
|
|
dependent.dependency_ids = [blocker.id, other_blocker.id]
|
|
await db_session.flush()
|
|
await svc._unblock_dependents(blocker.id)
|
|
refreshed = await svc.get(dependent.id)
|
|
assert refreshed is not None
|
|
# Still blocked because other_blocker is still in deps
|
|
assert refreshed.status == TaskStatus.BLOCKED
|
|
# but the one dependency that did complete is recorded.
|
|
assert blocker.id in refreshed.completed_dependency_ids
|
|
assert other_blocker.id not in refreshed.completed_dependency_ids
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claim — gate validations (team mismatch, role mismatch, self-review)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_rejects_when_agent_team_mismatch(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
# Create an agent on a different team
|
|
other = AgentTable(
|
|
id=uuid4(),
|
|
name="FE",
|
|
slug=f"fe-dev-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.FRONTEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup, team=Team.BACKEND))
|
|
task.branch_name = "feature/backend/x"
|
|
await db_session.flush()
|
|
out = await svc.claim(task.id, other.id)
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_rejects_self_review_for_qa(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
qa_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="QA",
|
|
slug=f"be-qa-{uuid4().hex[:8]}",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(qa_agent)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.branch_name = "feature/backend/x"
|
|
task.orchestration_markers = {"original_developer": str(qa_agent.id)}
|
|
await db_session.flush()
|
|
out = await svc.claim(task.id, qa_agent.id)
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_management_role_can_claim_any_team(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
main_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="MainPM",
|
|
slug=f"main-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup, team=Team.BACKEND))
|
|
task.branch_name = "feature/backend/x"
|
|
await db_session.flush()
|
|
claimed = await svc.claim(task.id, main_pm.id)
|
|
assert claimed is not None
|
|
assert claimed.assigned_to == main_pm.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pass_qa with notes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pass_qa_records_notes(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = 1
|
|
task.pr_url = "u"
|
|
await db_session.flush()
|
|
passed = await svc.pass_qa(task.id, notes="LGTM detailed", agent_role="qa")
|
|
assert passed is not None
|
|
assert passed.qa_notes == "LGTM detailed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pass_qa_resets_docs_complete(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Passing QA forces docs_complete=False so the documenter must redo."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.pr_number = 1
|
|
task.pr_url = "u"
|
|
task.docs_complete = True
|
|
await db_session.flush()
|
|
passed = await svc.pass_qa(task.id, agent_role="qa")
|
|
assert passed is not None
|
|
assert passed.docs_complete is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claim_seeds_branch via auto-create — failure rollback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_claim_rolls_back_on_branch_failure(
|
|
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""When _ensure_branch_for_task fails, claim fields revert."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
# No branch — claim will try to auto-create.
|
|
await db_session.flush()
|
|
|
|
async def _fail(*_args: object, **_kwargs: object) -> str:
|
|
raise ValueError("simulated branch failure")
|
|
|
|
monkeypatch.setattr(svc, "_ensure_branch_for_task", _fail)
|
|
with pytest.raises(ValueError, match="simulated branch failure"):
|
|
await svc.claim(task.id, task_setup["agent_id"])
|
|
|
|
refreshed = await svc.get(task.id)
|
|
assert refreshed is not None
|
|
# Status rolled back to PENDING
|
|
assert refreshed.status == TaskStatus.PENDING
|
|
assert refreshed.assigned_to is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# submit_for_pm_review — full happy path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_for_pm_review_returns_none_when_not_in_progress(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
out = await svc.submit_for_pm_review(task.id, agent_role="cell_pm")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_for_pm_review_returns_none_when_no_branch(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.branch_name = None
|
|
await db_session.flush()
|
|
out = await svc.submit_for_pm_review(task.id, agent_role="cell_pm")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_for_pm_review_returns_none_when_no_pr(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.branch_name = "feature/backend/x"
|
|
task.pr_created = False
|
|
await db_session.flush()
|
|
out = await svc.submit_for_pm_review(task.id, agent_role="cell_pm")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_for_pm_review_returns_none_when_active_descendants(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
parent.status = TaskStatus.IN_PROGRESS
|
|
parent.branch_name = "feature/backend/x"
|
|
parent.pr_created = True
|
|
parent.pr_number = 1
|
|
await db_session.flush()
|
|
# Create a non-terminal child
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
await db_session.flush()
|
|
assert child is not None # ensure child exists
|
|
out = await svc.submit_for_pm_review(parent.id, agent_role="cell_pm")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_for_pm_review_advances_with_notes(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.branch_name = "feature/backend/x"
|
|
task.pr_created = True
|
|
task.pr_number = 1
|
|
task.docs_complete = True
|
|
await db_session.flush()
|
|
out = await svc.submit_for_pm_review(
|
|
task.id, agent_role="cell_pm", notes="ready for review"
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.AWAITING_PM_REVIEW
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_for_pm_review_waives_branch_pr_for_batch_umbrella(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A MegaTask umbrella is branchless by design yet must walk
|
|
in_progress -> awaiting_pm_review; submit_for_pm_review waives the
|
|
branch+PR requirement for a batch umbrella so completion does not deadlock."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.project_id = None # umbrella: branchless, no repo, no PR
|
|
task.product_id = None
|
|
task.batch_id = uuid4()
|
|
task.parent_task_id = None # umbrella is top-level
|
|
task.branch_name = None
|
|
task.pr_created = False
|
|
task.pr_number = None
|
|
await db_session.flush()
|
|
out = await svc.submit_for_pm_review(task.id, agent_role="main_pm")
|
|
assert out is not None
|
|
assert out.status == TaskStatus.AWAITING_PM_REVIEW
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activate_batch_root_subtasks_retypes_code_to_planning(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A board-routed MegaTask root-subtask is created in BACKLOG with
|
|
task_type=code; _activate_batch_root_subtasks must retype it code->planning
|
|
when flipping team to main_pm, mirroring approve_and_start, or the
|
|
main_pm+code combo recurs."""
|
|
svc = task_setup["svc"]
|
|
# approve_and_start resolves the main-pm agent by slug — seed it.
|
|
main_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="Main PM",
|
|
slug="main-pm",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="main-pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm)
|
|
await db_session.flush()
|
|
|
|
batch = uuid4()
|
|
# Umbrella: board-routed, pending, board review complete, branchless.
|
|
umbrella = await svc.create(_req(task_setup, title="umbrella"))
|
|
umbrella.status = TaskStatus.PENDING
|
|
umbrella.team = cast("Any", Team.BOARD)
|
|
umbrella.board_review_complete = True
|
|
umbrella.project_id = None
|
|
umbrella.product_id = None
|
|
umbrella.batch_id = batch
|
|
umbrella.parent_task_id = None
|
|
umbrella.task_type = cast("Any", TaskType.PLANNING)
|
|
await db_session.flush()
|
|
|
|
# Root-subtask: held in BACKLOG on the board, code-typed (the danger case).
|
|
child = await svc.create(
|
|
_req(task_setup, title="root-sub", parent_task_id=umbrella.id)
|
|
)
|
|
child.status = TaskStatus.BACKLOG
|
|
child.team = cast("Any", Team.BOARD)
|
|
child.batch_id = batch
|
|
child.parent_task_id = umbrella.id
|
|
child.task_type = cast("Any", TaskType.CODE)
|
|
await db_session.flush()
|
|
|
|
approved = await svc.approve_and_start(umbrella.id)
|
|
assert approved is not None
|
|
|
|
refreshed = await svc.get(child.id)
|
|
assert refreshed is not None
|
|
assert refreshed.status == TaskStatus.PENDING
|
|
assert refreshed.team == Team.MAIN_PM
|
|
# The fix: a main_pm team may not own a code task -> retype to planning.
|
|
assert refreshed.task_type == TaskType.PLANNING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# docs_complete: full path including descendants check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_docs_complete_returns_none_when_active_descendants(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
parent.status = TaskStatus.AWAITING_DOCUMENTATION
|
|
parent.assigned_to = task_setup["agent_id"]
|
|
parent.pr_number = 1
|
|
parent.pr_url = "u"
|
|
await db_session.flush()
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
await db_session.flush()
|
|
assert child is not None
|
|
out = await svc.docs_complete(parent.id, doc_notes="docs done")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_docs_complete_returns_none_when_invalid_status(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
out = await svc.docs_complete(task.id, doc_notes="docs")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_docs_complete_advances_when_pr_already_created(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_DOCUMENTATION
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.pr_number = 1
|
|
task.pr_url = "u"
|
|
task.pr_created = True
|
|
await db_session.flush()
|
|
out = await svc.docs_complete(task.id, doc_notes="documented all flows")
|
|
assert out is not None
|
|
assert out.docs_complete is True
|
|
# Both flags now true → advances to AWAITING_PM_REVIEW
|
|
assert out.status == TaskStatus.AWAITING_PM_REVIEW
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mark_pr_created edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_pr_created_when_docs_not_complete_keeps_status(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_DOCUMENTATION
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.docs_complete = False
|
|
await db_session.flush()
|
|
pr_num = 10
|
|
out = await svc.mark_pr_created(task.id, pr_number=pr_num, pr_url="u10")
|
|
assert out is not None
|
|
# PR set, but stays in awaiting_documentation
|
|
assert out.pr_number == pr_num
|
|
assert out.status == TaskStatus.AWAITING_DOCUMENTATION
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# complete — happy path & edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_returns_none_when_invalid_status(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup)) # PENDING
|
|
out = await svc.complete(task.id, agent_id=task_setup["agent_id"])
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_returns_none_when_active_descendants(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
parent.status = TaskStatus.AWAITING_PM_REVIEW
|
|
await db_session.flush()
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
await db_session.flush()
|
|
assert child is not None
|
|
out = await svc.complete(parent.id, agent_id=task_setup["agent_id"])
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_in_progress_for_own_task(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""PM completes their own in_progress task (not awaiting_pm_review)."""
|
|
svc = task_setup["svc"]
|
|
pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm_agent)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = pm_agent.id
|
|
await db_session.flush()
|
|
out = await svc.complete(task.id, agent_id=pm_agent.id)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_cell_pm_does_not_escalate_to_main_pm(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""#178: cell_pm completing an awaiting_pm_review non-root task
|
|
transitions it to COMPLETED and does NOT reassign to main_pm.
|
|
|
|
Pre-#178 the cell_pm branch of ``_apply_complete_approval_chain``
|
|
reassigned every awaiting_pm_review task to main_pm and kept it in
|
|
awaiting_pm_review for a second-tier review — but the gateway's
|
|
``main_pm_complete`` rejects every non-root task
|
|
(``parent_task_id IS NOT NULL`` → invalid_state), so main_pm had
|
|
no verb to advance it. Result: the leaf was permanently wedged
|
|
(observed end-to-end this session). The fix removes the cell_pm
|
|
escalation branch; cell PM now completes non-root tasks directly,
|
|
and cell→main escalation, when intended, uses ``submit_up``.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
cell_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="CellPM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
main_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="MainPM",
|
|
slug=f"main-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add_all([cell_pm, main_pm])
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
task.assigned_to = cell_pm.id
|
|
await db_session.flush()
|
|
out = await svc.complete(task.id, agent_id=cell_pm.id)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.COMPLETED
|
|
assert out.assigned_to != main_pm.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_cell_pm_no_main_pm_completes(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""#178: cell_pm completing awaiting_pm_review transitions to
|
|
COMPLETED regardless of whether a Main PM exists. (Pre-#178 this
|
|
test guarded the "no Main PM → escalation returns None → falls
|
|
through to completion" fallback; post-#178 the cell_pm escalation
|
|
branch is gone entirely, so this path is the only path.)
|
|
"""
|
|
svc = task_setup["svc"]
|
|
cell_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="CellPM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(cell_pm)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_PM_REVIEW
|
|
await db_session.flush()
|
|
out = await svc.complete(task.id, agent_id=cell_pm.id)
|
|
assert out is not None
|
|
# No Main PM in scope — escalation returned None, falls through to completion
|
|
assert out.status == TaskStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_with_force_with_cancelled_succeeds(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""force_with_cancelled allows completion despite cancelled descendants.
|
|
|
|
Strip any leaked Main PMs so the Cell PM completion doesn't escalate
|
|
upward and short-circuit the cancelled-descendant code path.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
await db_session.execute(
|
|
cast("Table", AgentTable.__table__)
|
|
.update()
|
|
.where(AgentTable.role == AgentRole.MAIN_PM)
|
|
.values(role=AgentRole.SYSTEM)
|
|
)
|
|
pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm_agent)
|
|
await db_session.flush()
|
|
parent = await svc.create(_req(task_setup))
|
|
parent.status = TaskStatus.AWAITING_PM_REVIEW
|
|
await db_session.flush()
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
child.status = TaskStatus.CANCELLED
|
|
await db_session.flush()
|
|
out = await svc.complete(
|
|
parent.id,
|
|
agent_id=pm_agent.id,
|
|
force_with_cancelled=True,
|
|
justification="not needed anymore",
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_without_force_with_cancelled_blocks(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Without force_with_cancelled, cancelled descendants block completion."""
|
|
svc = task_setup["svc"]
|
|
await db_session.execute(
|
|
cast("Table", AgentTable.__table__)
|
|
.update()
|
|
.where(AgentTable.role == AgentRole.MAIN_PM)
|
|
.values(role=AgentRole.SYSTEM)
|
|
)
|
|
pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm_agent)
|
|
await db_session.flush()
|
|
parent = await svc.create(_req(task_setup))
|
|
parent.status = TaskStatus.AWAITING_PM_REVIEW
|
|
await db_session.flush()
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
child.status = TaskStatus.CANCELLED
|
|
await db_session.flush()
|
|
out = await svc.complete(parent.id, agent_id=pm_agent.id)
|
|
assert out is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# apply_escalation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_escalation_reassigns_and_blocks(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
target = AgentTable(
|
|
id=uuid4(),
|
|
name="Target",
|
|
slug=f"target-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="t",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(target)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
await db_session.flush()
|
|
await svc.apply_escalation(
|
|
task=task,
|
|
target_agent_id=target.id,
|
|
escalator_slug="dev-1",
|
|
target_slug="cell-pm",
|
|
reason="external blocker",
|
|
)
|
|
assert task.status == TaskStatus.BLOCKED
|
|
assert task.assigned_to == target.id
|
|
assert task.blocker_raised_by == task_setup["agent_id"]
|
|
# The escalation is a structured marker, NOT a developer note.
|
|
assert not (task.dev_notes or "")
|
|
esc = (task.orchestration_markers or {})["escalation"]
|
|
assert esc == {"from": "dev-1", "to": "cell-pm", "reason": "external blocker"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_apply_escalation_snapshots_original_owner_for_restore(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Escalation snapshots the original owner; restore returns it, not the target."""
|
|
svc = task_setup["svc"]
|
|
target = AgentTable(
|
|
id=uuid4(),
|
|
name="Target",
|
|
slug=f"target-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="t",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(target)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.claimed_by = task_setup["agent_id"]
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.branch_name = "feature/backend/abc12345"
|
|
await db_session.flush()
|
|
await svc.apply_escalation(
|
|
task=task,
|
|
target_agent_id=target.id,
|
|
escalator_slug="dev-1",
|
|
target_slug="cell-pm",
|
|
reason="external blocker",
|
|
)
|
|
# The snapshot captured the outgoing dev, not the escalation target.
|
|
assert task.pre_block_assignee == task_setup["agent_id"]
|
|
out = await svc.unblock_with_restore(
|
|
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.IN_PROGRESS
|
|
assert out.assigned_to == task_setup["agent_id"]
|
|
assert out.claimed_by == task_setup["agent_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# escalate / escalate_up_to_role helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_returns_none_for_missing_task(task_setup: dict) -> None:
|
|
svc = task_setup["svc"]
|
|
out = await svc.escalate(task_setup["agent_id"], uuid4(), reason="x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_returns_none_for_missing_agent(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
out = await svc.escalate(uuid4(), task.id, reason="x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_returns_none_when_target_slug_not_in_db(
|
|
task_setup: dict,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Escalation target slug exists in config but no AgentTable row matches."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
# Force a target slug that won't match any AgentTable row
|
|
monkeypatch.setattr(
|
|
"roboco.agents_config.get_escalation_target",
|
|
lambda _slug: "nonexistent-target",
|
|
)
|
|
out = await svc.escalate(task_setup["agent_id"], task.id, reason="x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_succeeds_when_target_resolves(
|
|
task_setup: dict,
|
|
db_session: AsyncSession,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
target = AgentTable(
|
|
id=uuid4(),
|
|
name="Target",
|
|
slug=f"esc-target-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="t",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(target)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
await db_session.flush()
|
|
monkeypatch.setattr(
|
|
"roboco.agents_config.get_escalation_target",
|
|
lambda _slug: target.slug,
|
|
)
|
|
out = await svc.escalate(task_setup["agent_id"], task.id, reason="bug")
|
|
assert out is not None
|
|
assert out.assigned_to == target.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_to_role_returns_none_for_missing_task(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
out = await svc.escalate_up_to_role(task_setup["agent_id"], uuid4(), "main_pm", "x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_to_role_returns_none_for_missing_agent(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
out = await svc.escalate_up_to_role(uuid4(), task.id, "main_pm", "x")
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_to_role_succeeds(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
main_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="MainPM",
|
|
slug=f"main-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="m",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
await db_session.flush()
|
|
out = await svc.escalate_up_to_role(
|
|
task_setup["agent_id"], task.id, "main_pm", "needs main pm"
|
|
)
|
|
assert out is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_escalate_up_to_role_returns_none_when_no_target_role(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Target role has no agents in DB.
|
|
|
|
Strip any leaked Main PMs so escalation hits the no-target branch.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
await db_session.execute(
|
|
cast("Table", AgentTable.__table__)
|
|
.update()
|
|
.where(AgentTable.role == AgentRole.MAIN_PM)
|
|
.values(role=AgentRole.SYSTEM)
|
|
)
|
|
task = await svc.create(_req(task_setup))
|
|
await db_session.flush()
|
|
out = await svc.escalate_up_to_role(task_setup["agent_id"], task.id, "main_pm", "x")
|
|
# No main_pm in scope; the unknown-role branch would also short-circuit
|
|
assert out is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# unblock_with_restore - full snapshot path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_with_restore_with_invalid_pre_block_state(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""An invalid pre_block_state value falls through to legacy unblock."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.BLOCKED
|
|
task.pre_block_state = "garbage"
|
|
# Has a branch (was claimed before blocking) so legacy unblock resumes
|
|
# in_progress rather than returning a never-claimed task to pending.
|
|
task.branch_name = "feature/backend/abc12345"
|
|
await db_session.flush()
|
|
out = await svc.unblock_with_restore(
|
|
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
|
|
)
|
|
# Falls through to legacy unblock which transitions to in_progress
|
|
assert out is not None
|
|
assert out.status == TaskStatus.IN_PROGRESS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_with_restore_when_status_not_blocked(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.pre_block_state = TaskStatus.IN_PROGRESS.value
|
|
await db_session.flush()
|
|
out = await svc.unblock_with_restore(
|
|
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
|
|
)
|
|
# status not BLOCKED but pre_block_state is set → returns None
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_soft_block_snapshots_pre_block_state(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""soft_block records the resting status + owner for restore=True."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.branch_name = "feature/backend/abc12345"
|
|
await db_session.flush()
|
|
await svc.soft_block(
|
|
task.id, SoftBlockInfo(reason="x", blocker_type="ext", what_needed="y")
|
|
)
|
|
assert task.status == TaskStatus.BLOCKED
|
|
assert task.pre_block_state == TaskStatus.IN_PROGRESS.value
|
|
assert task.pre_block_assignee == task_setup["agent_id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_with_restore_returns_to_snapshot(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""restore=True returns the task to its snapshotted status + owner."""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
task.branch_name = "feature/backend/abc12345"
|
|
await db_session.flush()
|
|
await svc.soft_block(
|
|
task.id, SoftBlockInfo(reason="x", blocker_type="ext", what_needed="y")
|
|
)
|
|
out = await svc.unblock_with_restore(
|
|
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.IN_PROGRESS
|
|
assert out.assigned_to == task_setup["agent_id"]
|
|
assert out.claimed_by == task_setup["agent_id"]
|
|
# Snapshot is consumed so a later block re-captures fresh.
|
|
assert out.pre_block_state is None
|
|
assert out.pre_block_assignee is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unblock_with_restore_branchless_diverts_to_pending(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A snapshot of in_progress with no branch restores to pending, not in_progress.
|
|
|
|
Restoring a branchless task to in_progress would loop the dispatcher; the
|
|
restore path applies the same branchless guard legacy unblock() uses.
|
|
"""
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.BLOCKED
|
|
task.pre_block_state = TaskStatus.IN_PROGRESS.value
|
|
task.pre_block_assignee = task_setup["agent_id"]
|
|
task.branch_name = None
|
|
await db_session.flush()
|
|
out = await svc.unblock_with_restore(
|
|
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.PENDING
|
|
assert out.assigned_to == task_setup["agent_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# qa_pass and qa_fail with actor mismatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_pass_logs_actor_mismatch(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
qa_a = AgentTable(
|
|
id=uuid4(),
|
|
name="QA-A",
|
|
slug=f"be-qa-a-{uuid4().hex[:8]}",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
qa_b = AgentTable(
|
|
id=uuid4(),
|
|
name="QA-B",
|
|
slug=f"be-qa-b-{uuid4().hex[:8]}",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add_all([qa_a, qa_b])
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.claimed_by = qa_a.id # Different from qa_b
|
|
task.pr_number = 1
|
|
task.pr_url = "u"
|
|
await db_session.flush()
|
|
out = await svc.qa_pass(qa_b.id, task.id, "looks ok")
|
|
# Pass succeeds despite mismatch (warning logged)
|
|
assert out is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_fail_appends_issues_and_calls_fail_qa(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
qa_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="QA",
|
|
slug=f"be-qa-{uuid4().hex[:8]}",
|
|
role=AgentRole.QA,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="qa",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(qa_agent)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.AWAITING_QA
|
|
task.claimed_by = qa_agent.id
|
|
await db_session.flush()
|
|
out = await svc.qa_fail(
|
|
qa_agent.id,
|
|
task.id,
|
|
notes="needs work",
|
|
issues=["typo", "missing test"],
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.NEEDS_REVISION
|
|
# Issues block was appended to dev_notes
|
|
assert "typo" in (out.dev_notes or "")
|
|
assert "missing test" in (out.dev_notes or "")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_qa_fail_returns_none_for_missing_task(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
out = await svc.qa_fail(uuid4(), uuid4(), notes="x", issues=[])
|
|
assert out is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pause_for_agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_for_agent_returns_none_when_wrong_assignee(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
other = AgentTable(
|
|
id=uuid4(),
|
|
name="Other",
|
|
slug=f"other-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(other)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = other.id
|
|
await db_session.flush()
|
|
out = await svc.pause_for_agent(
|
|
agent_id=task_setup["agent_id"], task_id=task.id, agent_role="developer"
|
|
)
|
|
assert out is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_for_agent_calls_pause_when_owner(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
out = await svc.pause_for_agent(
|
|
agent_id=task_setup["agent_id"],
|
|
task_id=task.id,
|
|
agent_role="developer",
|
|
)
|
|
assert out is not None
|
|
assert out.status == TaskStatus.PAUSED
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_in_progress_for_agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_in_progress_for_agent(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
rows = await svc.list_in_progress_for_agent(task_setup["agent_id"])
|
|
assert task.id in {t.id for t in rows}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_subtask
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_subtask_requires_parent_task_id(task_setup: dict) -> None:
|
|
svc = task_setup["svc"]
|
|
with pytest.raises(ValueError, match="parent_task_id"):
|
|
await svc.create_subtask(_req(task_setup, parent_task_id=None))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_subtask_with_assignee_uses_pending(
|
|
task_setup: dict,
|
|
) -> None:
|
|
# `create_subtask` enforces TASK_AT_CREATE completeness (Task 18, 2026-05-10),
|
|
# so we pass a description that meets the 20-char minimum.
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
sub = await svc.create_subtask(
|
|
_req(
|
|
task_setup,
|
|
description="Subtask with explicit description for completeness rule.",
|
|
parent_task_id=parent.id,
|
|
assigned_to=task_setup["agent_id"],
|
|
)
|
|
)
|
|
assert sub.status == TaskStatus.PENDING
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_subtask_without_assignee_uses_backlog(
|
|
task_setup: dict,
|
|
) -> None:
|
|
# See test above re: completeness rule on description length.
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup))
|
|
sub = await svc.create_subtask(
|
|
_req(
|
|
task_setup,
|
|
description="Subtask with explicit description for completeness rule.",
|
|
parent_task_id=parent.id,
|
|
assigned_to=None,
|
|
)
|
|
)
|
|
assert sub.status == TaskStatus.BACKLOG
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# submit_pm_review (gateway alias)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_pm_review_with_notes(
|
|
task_setup: dict,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
cell_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(cell_pm)
|
|
await db_session.flush()
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.branch_name = "feature/backend/x"
|
|
task.pr_created = True
|
|
task.pr_number = 1
|
|
await db_session.flush()
|
|
out = await svc.submit_pm_review(cell_pm.id, task.id, "ready")
|
|
assert out is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_agent_id failure path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_agent_id_raises_for_unknown_slug(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
with pytest.raises(NotFoundError):
|
|
await svc.resolve_agent_id("nonexistent-slug")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# main_pm_agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_agent_returns_seeded(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
main_pm = AgentTable(
|
|
id=uuid4(),
|
|
name="MainPM",
|
|
slug=f"main-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.MAIN_PM,
|
|
team=Team.MAIN_PM,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="m",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(main_pm)
|
|
await db_session.flush()
|
|
out = await svc.main_pm_agent()
|
|
assert out is not None
|
|
assert out.role == AgentRole.MAIN_PM
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_active_task_for_agent — returns the most recent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_task_for_agent_picks_in_progress(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
task = await svc.create(_req(task_setup))
|
|
task.status = TaskStatus.IN_PROGRESS
|
|
task.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
out = await svc.get_active_task_for_agent(task_setup["agent_id"])
|
|
assert out is not None
|
|
assert out.id == task.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_paused_for_agent — returns paused tasks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_paused_for_agent_returns_paused_only(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
paused = await svc.create(_req(task_setup))
|
|
paused.status = TaskStatus.PAUSED
|
|
paused.assigned_to = task_setup["agent_id"]
|
|
await db_session.flush()
|
|
rows = await svc.list_paused_for_agent(task_setup["agent_id"])
|
|
assert paused.id in {t.id for t in rows}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_long_running_blocked
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_long_running_blocked_returns_list(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
rows = await svc.list_long_running_blocked(threshold_minutes=1)
|
|
assert isinstance(rows, list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_strategic_for_board
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_strategic_for_board_excludes_subtasks(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""Only root tasks (no parent) with NON_TECHNICAL nature in PM review."""
|
|
svc = task_setup["svc"]
|
|
parent = await svc.create(_req(task_setup, nature=TaskNature.NON_TECHNICAL))
|
|
parent.status = TaskStatus.AWAITING_PM_REVIEW
|
|
sub = await svc.create(
|
|
_req(
|
|
task_setup,
|
|
nature=TaskNature.NON_TECHNICAL,
|
|
parent_task_id=parent.id,
|
|
)
|
|
)
|
|
sub.status = TaskStatus.AWAITING_PM_REVIEW
|
|
await db_session.flush()
|
|
rows = await svc.list_strategic_for_board()
|
|
ids = {t.id for t in rows}
|
|
assert parent.id in ids
|
|
assert sub.id not in ids
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# emit_task_event coverage — best-effort
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _BadBus:
|
|
def is_connected(self) -> bool:
|
|
raise RuntimeError("bus dead")
|
|
|
|
|
|
def _bad_bus_factory() -> _BadBus:
|
|
return _BadBus()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_task_event_swallows_exception(
|
|
task_setup: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""If event bus raises, _emit_task_event must log + return without raising."""
|
|
svc = task_setup["svc"]
|
|
monkeypatch.setattr("roboco.services.task.get_event_bus", _bad_bus_factory)
|
|
# No raise:
|
|
await svc._emit_task_event(EventType.TASK_AWAITING_CEO_APPROVAL, uuid4())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_task_event_publishes_when_connected(
|
|
task_setup: dict, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
publish_mock = AsyncMock()
|
|
|
|
class _Bus:
|
|
def is_connected(self) -> bool:
|
|
return True
|
|
|
|
async def publish(self, event: Any) -> None:
|
|
await publish_mock(event)
|
|
|
|
def _bus_factory() -> _Bus:
|
|
return _Bus()
|
|
|
|
monkeypatch.setattr("roboco.services.task.get_event_bus", _bus_factory)
|
|
await svc._emit_task_event(
|
|
EventType.TASK_AWAITING_CEO_APPROVAL, uuid4(), {"key": "value"}
|
|
)
|
|
publish_mock.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_pm_for_review chain walks up parents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_pm_for_review_finds_assignee_up_chain(
|
|
task_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
pm_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm_agent)
|
|
await db_session.flush()
|
|
grandparent = await svc.create(_req(task_setup, assigned_to=pm_agent.id))
|
|
parent = await svc.create(_req(task_setup, parent_task_id=grandparent.id))
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
pm_id = await svc._resolve_pm_for_review(child)
|
|
assert pm_id == pm_agent.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_pm_for_review_returns_none_when_root(
|
|
task_setup: dict,
|
|
) -> None:
|
|
svc = task_setup["svc"]
|
|
root = await svc.create(_req(task_setup))
|
|
pm_id = await svc._resolve_pm_for_review(root)
|
|
assert pm_id is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_pm_for_review_returns_none_when_parent_chain_unassigned(
|
|
task_setup: dict,
|
|
) -> None:
|
|
"""Edge case: when no ancestor in the chain has an assignee."""
|
|
svc = task_setup["svc"]
|
|
grand = await svc.create(_req(task_setup)) # Unassigned
|
|
parent = await svc.create(_req(task_setup, parent_task_id=grand.id)) # Unassigned
|
|
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
|
|
pm_id = await svc._resolve_pm_for_review(child)
|
|
assert pm_id is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# H3: complete() from IN_PROGRESS allowed only for leaf / branchless
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _seed_pm_and_task(
|
|
db_session: AsyncSession,
|
|
*,
|
|
status: TaskStatus,
|
|
tid: UUID,
|
|
parent_task_id: UUID | None = None,
|
|
branch_name: str = "feature/backend/cell",
|
|
) -> UUID:
|
|
"""Seed a PM + project + task and return the PM id."""
|
|
|
|
pm = AgentTable(
|
|
id=uuid4(),
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="H3",
|
|
slug=f"h3-{uuid4().hex[:6]}",
|
|
git_url="https://example.com/r.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=pm.id,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
db_session.add(
|
|
TaskTable(
|
|
id=tid,
|
|
title="t",
|
|
description="d",
|
|
acceptance_criteria=["done"],
|
|
status=status,
|
|
priority=2,
|
|
task_type=TaskType.PLANNING,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.LOW,
|
|
team=Team.BACKEND,
|
|
confirmed_by_human=True,
|
|
assigned_to=pm.id,
|
|
active_claimant_id=pm.id,
|
|
claimed_by=pm.id,
|
|
project_id=project.id,
|
|
created_by=pm.id,
|
|
parent_task_id=parent_task_id,
|
|
branch_name=branch_name,
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
return cast("UUID", pm.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_refuses_in_progress_on_assembled_cell_task(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""A PM owning a cell task (IN_PROGRESS, terminal subtask) must NOT be
|
|
able to complete() directly from IN_PROGRESS — that bypasses submit_up
|
|
and the entire PR-review gate."""
|
|
|
|
pm_id = uuid4()
|
|
cell_id = uuid4()
|
|
child_id = uuid4()
|
|
pm = AgentTable(
|
|
id=pm_id,
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="CellProj",
|
|
slug=f"cell-{uuid4().hex[:6]}",
|
|
git_url="https://example.com/r.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=pm.id,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
db_session.add_all(
|
|
[
|
|
TaskTable(
|
|
id=cell_id,
|
|
title="cell",
|
|
description="d",
|
|
acceptance_criteria=["done"],
|
|
status=TaskStatus.IN_PROGRESS,
|
|
priority=2,
|
|
task_type=TaskType.PLANNING,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.LOW,
|
|
team=Team.BACKEND,
|
|
confirmed_by_human=True,
|
|
assigned_to=pm_id,
|
|
active_claimant_id=pm_id,
|
|
claimed_by=pm_id,
|
|
project_id=project.id,
|
|
created_by=pm_id,
|
|
branch_name="feature/backend/cell",
|
|
),
|
|
TaskTable(
|
|
id=child_id,
|
|
title="child",
|
|
description="d",
|
|
acceptance_criteria=["done"],
|
|
status=TaskStatus.COMPLETED,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.LOW,
|
|
team=Team.BACKEND,
|
|
confirmed_by_human=True,
|
|
project_id=project.id,
|
|
created_by=pm_id,
|
|
parent_task_id=cell_id,
|
|
branch_name="feature/backend/cell--child",
|
|
),
|
|
]
|
|
)
|
|
await db_session.flush()
|
|
|
|
svc = get_task_service(db_session)
|
|
result = await svc.complete(cell_id, agent_id=pm_id)
|
|
assert result is None, (
|
|
"PM completed an assembled cell task from IN_PROGRESS, bypassing "
|
|
"submit_up + the PR-review gate (H3)"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_allows_in_progress_on_leaf(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""A leaf (no descendants) the PM owns at IN_PROGRESS completes — the
|
|
PM-self path stays open."""
|
|
|
|
leaf_id = uuid4()
|
|
pm_id = await _seed_pm_and_task(
|
|
db_session,
|
|
status=TaskStatus.IN_PROGRESS,
|
|
tid=leaf_id,
|
|
branch_name="feature/backend/leaf",
|
|
)
|
|
svc = get_task_service(db_session)
|
|
result = await svc.complete(leaf_id, agent_id=pm_id)
|
|
assert result is not None and result.status == TaskStatus.COMPLETED
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# H5: _unclaim_from_blocked must clear the stale pre-block snapshot
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclaim_from_blocked_clears_pre_block_snapshot(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""A blocked task unclaimed back to the pool must not keep a stale
|
|
pre_block snapshot — a later re-block + unblock_with_restore would
|
|
restore the stale owner/status."""
|
|
|
|
tid = uuid4()
|
|
owner = uuid4()
|
|
pm = AgentTable(
|
|
id=owner,
|
|
name="PM",
|
|
slug=f"be-pm-{uuid4().hex[:8]}",
|
|
role=AgentRole.CELL_PM,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="pm",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(pm)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="H5",
|
|
slug=f"h5-{uuid4().hex[:6]}",
|
|
git_url="https://example.com/r.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=owner,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
db_session.add(
|
|
TaskTable(
|
|
id=tid,
|
|
title="t",
|
|
description="d",
|
|
acceptance_criteria=["done"],
|
|
status=TaskStatus.BLOCKED,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
estimated_complexity=Complexity.LOW,
|
|
team=Team.BACKEND,
|
|
confirmed_by_human=True,
|
|
assigned_to=owner,
|
|
active_claimant_id=owner,
|
|
claimed_by=owner,
|
|
project_id=project.id,
|
|
created_by=owner,
|
|
branch_name="feature/x",
|
|
pre_block_state=TaskStatus.IN_PROGRESS.value,
|
|
pre_block_assignee=owner,
|
|
pre_block_metadata={"reason": "stale"},
|
|
)
|
|
)
|
|
await db_session.flush()
|
|
|
|
svc = get_task_service(db_session)
|
|
await svc._unclaim_from_blocked(
|
|
(
|
|
await db_session.execute(select(TaskTable).where(TaskTable.id == tid))
|
|
).scalar_one()
|
|
)
|
|
|
|
row = (
|
|
await db_session.execute(select(TaskTable).where(TaskTable.id == tid))
|
|
).scalar_one()
|
|
assert row.pre_block_state is None
|
|
assert row.pre_block_assignee is None
|
|
assert row.pre_block_metadata is None
|
|
assert row.blocker_resolver_type is None
|
|
assert row.blocker_raised_by is None
|