Fix: dependency spawn gate and cell ownership (#73)

* Cleanup + Missing greenlet error

* fix(messaging): persist a group's active-session pointer so posts reuse it

create_session and create_session_with_access_check set group.active_session_id
from session.id BEFORE the flush that materializes it — the id is a flush-time
uuid4 default, so the pointer was written as NULL and every post opened a fresh
session, fragmenting one conversation across many. Flush first, then link, the
same ordering the seed path already uses.

Two tests fabricated "two distinct sessions" by calling create_session twice on
one group, which only differed because of this bug; switch them to two groups so
they keep testing their real intent. Add a regression guard that the pointer is
actually persisted and a second create reuses the live session.

* fix(orchestrator): gate spawns on dependencies and keep cell tasks in their cell

The cross-task dependency check ran only on the dev dispatch path, so cell-PM,
Main-PM and board agents were spawned onto dependency-blocked tasks and flailed
unblock / escalate / notify against an unfinished upstream — climbing ownership
of cell work up to the board, which cannot drive it, and deadlocking the task.

- Move the dependency gate into the shared spawn readiness check so it covers
  every role, and auto-block the task so it leaves the pending pool until the
  upstream reaches a terminal state (then the existing auto-unblock revives it).
- Cell-ownership invariant: a backend/frontend/ux_ui task may only be worked or
  owned by its own cell. The readiness gate refuses a board or Main-PM spawn
  onto a cell task; reassign refuses and clears such an owner; and on
  dependency-clear a mis-owned cell task is re-homed to its cell's pending pool
  instead of reviving under an owner that cannot progress it.
- A dependency block is never a CEO signal: notify(target=ceo) is refused while
  the task is waiting on an unfinished upstream, with a remediate to idle and
  wait — the block clears on its own.

* Uploading images + Fixing pyproject.toml

* ++

* revert(orchestrator): drop the cell-ownership block pending a tooling audit

The cell-ownership invariant added earlier — a board / Main-PM role may never be
spawned onto or reassigned to a cell task, plus re-homing a mis-owned cell task
on dependency-clear — was too absolute. It forbids a higher role from stepping
in when something genuinely deeper is going on, and contradicts the existing
rule that main_pm may hold a task at awaiting_pm_review. The dependency spawn
gate already prevents the cascade that handed the board cell tasks; the deadlock
it guarded against will be addressed with a return-path approach after auditing
what tools the cell PMs actually need. Keeps the dependency gate and the CEO
dependency-block notify guard.

* docs(prompts): a dependency wait is wait-and-idle, not escalate

The cell-PM and Main-PM prompts told agents to escalate_up / retry unblock on a
blocked task without distinguishing a dependency wait (which auto-clears the
moment the upstream completes) from a real wedge — the source of the
escalate/unblock flail and the CEO-notification spam. Split the blocked-state
guidance: a cross-cell dependency wait = note + i_am_idle (do not escalate,
unblock, or notify the CEO); escalate only a genuinely broken upstream. Fix two
stale references to i_am_blocked, a developer-only verb the PMs do not have,
to escalate_up.

Correct the CLAUDE.md verb-surface table, which understated every role: it
listed 4 cell_pm verbs while the flow manifest derives the full set (11,
including unclaim and i_am_idle) from lifecycle.spec.intents_for_role.

* feat(gateway): cell_pm reassign verb — intra-cell developer hand-off

A cell PM can now hand a claimed/in_progress task to another developer in its
own cell without unclaim (which drops the work back to the pool and loses the
assignee). The branch is keyed to the task, so the work-in-progress is
preserved; the new dev is respawned to continue. Intra-cell only: the task must
be in the caller's cell and new_assignee must be a developer of that same cell.

Wired through every layer: the reassign IntentSpec (composes=(), cell_pm-only),
the choreographer verb + intra-cell guard, a reaper-safe
TaskService.reassign_active_claim (reseeds the claim heartbeat so the new dev
is not immediately reaped), the ReassignRequest schema, the cell_pm flow route,
and the MCP flow-server tool. Tracing-waived like unclaim (mechanical hand-off).
Regenerated lifecycle/verb artifacts; prompt + CLAUDE.md updated.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-06 22:10:48 +02:00
committed by GitHub
co-authored by Renn F
parent 8596c72d9b
commit 3205443119
50 changed files with 1991 additions and 8482 deletions
-22
View File
@@ -4,7 +4,6 @@ Factory Base Utilities
Shared utilities for agent factory functions.
"""
import re
from pathlib import Path
from typing import TYPE_CHECKING
@@ -239,27 +238,6 @@ def compose_prompt(
return "\n\n---\n\n".join(parts)
def load_blueprint_prompt(blueprint_path: str, default_prompt: str) -> str:
"""
Load system prompt from a blueprint file.
Args:
blueprint_path: Relative path to the blueprint markdown file
default_prompt: Default prompt if file doesn't exist or parsing fails
Returns:
The extracted system prompt or the default
"""
path = Path(blueprint_path)
if not path.exists():
return default_prompt
content = path.read_text()
# Extract system prompt section (between ```blocks after ## System Prompt)
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
return match.group(1).strip() if match else default_prompt
def make_slug(name: str) -> str:
"""Convert a name to a URL-safe slug."""
return name.lower().replace(" ", "-")
+12
View File
@@ -14,6 +14,7 @@ from roboco.api.schemas.v1.flow import (
GiveMeWorkRequest,
IAmIdleRequest,
IWillPlanRequest,
ReassignRequest,
ResumeRequest,
SubmitUpRequest,
TriageRequest,
@@ -154,6 +155,17 @@ async def unclaim(
return envelope_to_response(env, request)
@router.post("/reassign")
async def reassign(
request: Request,
body: ReassignRequest,
x_agent_id: _AgentIdHeader,
choreographer: _ChoreographerDep,
) -> dict:
env = await choreographer.reassign(x_agent_id, body.task_id, body.new_assignee)
return envelope_to_response(env, request)
@router.post("/resume")
async def resume(
request: Request,
+11
View File
@@ -84,6 +84,17 @@ class UnclaimRequest(BaseModel):
task_id: UUID
class ReassignRequest(BaseModel):
"""HTTP body for the cell_pm `reassign` verb.
``new_assignee`` is a developer slug in the caller's own cell (e.g.
``be-dev-2``). The choreographer resolves and validates it.
"""
task_id: UUID
new_assignee: str = Field(..., min_length=1)
class ResumeRequest(BaseModel):
task_id: UUID
+1 -4
View File
@@ -7,7 +7,6 @@ Data constants are in roboco/seeds/, database operations in roboco/db/seed.py.
import asyncio
from http import HTTPStatus
from pathlib import Path
import httpx
import structlog
@@ -103,9 +102,7 @@ async def main(
logger.info("Event bus initialized (Redis Streams)")
# Initialize orchestrator
orchestrator = AgentOrchestrator(
blueprints_dir=Path("agents/blueprints"),
)
orchestrator = AgentOrchestrator()
_BootstrapHolder.orchestrator = orchestrator
# Set orchestrator in API routes
+16
View File
@@ -864,6 +864,22 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
"task returned to pending; another agent (or you, fresh) can claim"
),
),
"reassign": IntentSpec(
name="reassign",
allowed_roles=frozenset({Role.CELL_PM}),
description=(
"Hand a claimed/in_progress task to another developer in your own"
" cell. The branch is keyed to the task (not the agent), so it is"
" preserved — the new developer continues the work-in-progress. No"
" status change."
),
composes=(), # special — the verb body owns the assignee write
extra_preconditions=(),
side_effects=(),
next_hint=lambda _t: (
"reassigned; the new developer will be respawned to continue"
),
),
"resume": IntentSpec(
name="resume",
allowed_roles=frozenset(_DEV_ROLES | _QA_ROLES | _DOC_ROLES | _PM_ROLES),
+1
View File
@@ -309,6 +309,7 @@ VERBS_WITHOUT_TRACING: frozenset[str] = frozenset(
"evidence", # read-only evidence dump
"i_am_idle", # signal only
"unclaim", # voluntary release; no rationale required
"reassign", # mechanical intra-cell hand-off; branch/WIP preserved
"resume", # pure state move paused→in_progress
# claim_review's tracing applies on pass_review / fail_review.
"claim_review",
+12
View File
@@ -303,6 +303,17 @@ def unclaim(task_id: str) -> dict[str, Any]:
return _post(_role_path("unclaim"), {"task_id": task_id})
def reassign(task_id: str, new_assignee: str) -> dict[str, Any]:
"""Cell PM: hand a claimed/in_progress task to another dev in your own cell.
The branch is keyed to the task, so the work-in-progress survives;
`new_assignee` is a developer slug in your cell (e.g. `be-dev-2`).
"""
return _post(
_role_path("reassign"), {"task_id": task_id, "new_assignee": new_assignee}
)
def resume(task_id: str) -> dict[str, Any]:
"""Resume a paused task. Transitions paused → in_progress for the assignee."""
return _post(_role_path("resume"), {"task_id": task_id})
@@ -492,6 +503,7 @@ _TOOLS: dict[str, Any] = {
"i_am_done": i_am_done,
"i_am_blocked": i_am_blocked,
"unclaim": unclaim,
"reassign": reassign,
"resume": resume,
"i_am_idle": i_am_idle,
# qa — keys are the public MCP tool names (what agents see and prompts
+18 -80
View File
@@ -485,12 +485,10 @@ class AgentOrchestrator:
def __init__(
self,
blueprints_dir: Path | None = None,
mcp_config_dir: Path | None = None,
project_root: Path | None = None,
dispatcher_interval: int = 30,
):
self.blueprints_dir = blueprints_dir or Path("agents/blueprints")
self.mcp_config_dir = mcp_config_dir or Path(".mcp")
self.project_root = project_root or Path.cwd()
self.dispatcher_interval = dispatcher_interval
@@ -1450,7 +1448,6 @@ class AgentOrchestrator:
mcp_name = config.mcp_config_path.name if config.mcp_config_path else ""
if PROJECT_HOST_PATH:
return {
"blueprints": f"{PROJECT_HOST_PATH}/agents/blueprints",
"docs": f"{PROJECT_HOST_PATH}/docs",
"workspaces": f"{DATA_HOST_PATH}/workspaces",
"claude": CLAUDE_AUTH_HOST_PATH,
@@ -1470,8 +1467,7 @@ class AgentOrchestrator:
),
}
return {
"blueprints": str(self.blueprints_dir.absolute()),
"docs": str(self.blueprints_dir.parent / "docs"),
"docs": str((self.project_root / "docs").absolute()),
"workspaces": str(Path(settings.workspaces_root)),
"claude": CLAUDE_AUTH_HOST_PATH,
"mcp_config": str(config.mcp_config_path),
@@ -1538,14 +1534,12 @@ class AgentOrchestrator:
def _core_volume_and_env_args(
config: AgentConfig, hosts: dict[str, str | None], role: str
) -> list[str]:
"""The always-on -v/-e block (prompt, blueprints, docs, workspaces, env)."""
"""The always-on -v/-e block (prompt, docs, workspaces, env)."""
docs_ro = "" if config.agent_id in ALL_DOCS else ":ro"
return [
"-v",
f"{hosts['prompt']}:/app/system-prompt.md:ro",
"-v",
f"{hosts['blueprints']}:/app/agents/blueprints:ro",
"-v",
f"{hosts['docs']}:/app/docs{docs_ro}",
"-v",
f"{hosts['workspaces']}:/data/workspaces",
@@ -2027,19 +2021,26 @@ class AgentOrchestrator:
return task_or_reason
task = task_or_reason
persistent = self._readiness_check_task(agent_id, task)
if persistent is not None:
return await self._readiness_block(client, task_id, persistent)
# Universal dependency gate: refuse to spawn an agent of ANY role
# onto a task whose cross-task dependencies are not yet terminal.
# This check previously lived only on the dev dispatch path, so
# cell-PM, Main-PM and board agents were spawned onto
# dependency-blocked tasks and flailed unblock / escalate / notify
# against an unfinished upstream. Auto-block so the task leaves the
# pending pool (no per-tick spawn-refusal that would starve
# siblings); `_unblock_dependents` revives it the moment the
# upstream reaches a terminal state.
if dep_reason := await self._check_dependencies_terminal(client, task):
return await self._readiness_block(client, task_id, dep_reason)
persistent = self._readiness_check_task(agent_id, task)
# Skip the git-token gate for coordination tasks — they have no
# project of their own, so there's no token to require.
if not _is_coordination_task(task):
if persistent is None and not _is_coordination_task(task):
project_slug = _read_project_slug(task)
token_reason = await self._readiness_check_git_token(project_slug)
if token_reason is not None:
return await self._readiness_block(
client, task_id, token_reason
)
persistent = await self._readiness_check_git_token(project_slug)
if persistent is not None:
return await self._readiness_block(client, task_id, persistent)
except httpx.HTTPError as e:
# Transient — retry on next dispatch without auto-blocking.
return f"readiness check HTTP error: {e}"
@@ -2376,69 +2377,6 @@ class AgentOrchestrator:
)
return path
def _get_blueprint_path(self, agent_id: str) -> Path:
"""Get blueprint path for an agent.
DEPRECATED: Use _generate_composed_prompt() instead.
Kept for backwards compatibility.
"""
role = self._get_blueprint_role(agent_id)
team = self._get_agent_team(agent_id)
if team == "backend":
cell_dir = "backend"
elif team == "frontend":
cell_dir = "frontend"
elif team == "ux_ui":
cell_dir = "ux_ui"
else:
cell_dir = "board"
blueprint_file = f"{role.replace('_', '-')}.md"
return self.blueprints_dir / cell_dir / blueprint_file
def _get_blueprint_rel_path(self, agent_id: str) -> str:
"""Get relative blueprint path for container mount."""
role = self._get_blueprint_role(agent_id)
team = self._get_agent_team(agent_id)
if team == "backend":
cell_dir = "backend"
elif team == "frontend":
cell_dir = "frontend"
elif team == "ux_ui":
cell_dir = "ux_ui"
else:
cell_dir = "board"
blueprint_file = f"{role.replace('_', '-')}.md"
return f"{cell_dir}/{blueprint_file}"
def _get_blueprint_role(self, agent_id: str) -> str:
"""Get blueprint-specific role name from agent_id (used for file paths)."""
role_map = {
"be-dev-1": "be-dev",
"be-dev-2": "be-dev",
"fe-dev-1": "fe-dev",
"fe-dev-2": "fe-dev",
"ux-dev-1": "ux-dev",
"ux-dev-2": "ux-dev",
"be-qa": "be-qa",
"fe-qa": "fe-qa",
"ux-qa": "ux-qa",
"be-pm": "be-pm",
"fe-pm": "fe-pm",
"ux-pm": "ux-pm",
"be-doc": "be-documenter",
"fe-doc": "fe-documenter",
"ux-doc": "ux-documenter",
"main-pm": "main-pm",
"product-owner": "product-owner",
"head-marketing": "head-marketing",
"auditor": "auditor",
}
return role_map.get(agent_id, agent_id)
# Slug -> team string for ROUTING purposes. Derived from
# foundation.AGENTS so adding/renaming an agent edits exactly one
# file (foundation/identity.py). The dispatcher relies on this for
@@ -2337,6 +2337,141 @@ class Choreographer:
context_briefing=briefing,
).with_introspection(task=after, role=role_str)
@staticmethod
def _validate_reassign(
t: Any, agent_id: UUID, new_assignee: str
) -> Envelope | None:
"""Intra-cell guard for ``reassign`` (verb body owns it — composes=()).
The task must be claimed/in_progress and in the caller's own cell, and
``new_assignee`` must be a developer of that same cell. Returns a
rejection envelope, or None when the hand-off is allowed.
"""
from roboco.agents_config import get_agent_role, get_agent_team
from roboco.seeds.initial_data import AGENT_UUIDS
caller_team = get_agent_team(str(agent_id))
task_team = getattr(t.team, "value", t.team)
status = str(getattr(t.status, "value", t.status))
if status not in ("claimed", "in_progress"):
return Envelope.invalid_state(
message=f"cannot reassign a task in status {status!r}",
remediate=(
"reassign only a claimed or in_progress task; review/terminal"
" states are owned by their lifecycle role"
),
context_briefing={},
)
if task_team is None or task_team != caller_team:
return Envelope.not_authorized(
message=f"task team {task_team!r} is not your cell ({caller_team!r})",
remediate="you can only reassign tasks inside your own cell",
context_briefing={},
)
if new_assignee not in AGENT_UUIDS:
return Envelope.invalid_state(
message=f"unknown agent slug {new_assignee!r}",
remediate=(
"new_assignee must be a developer slug in your cell, e.g. be-dev-2"
),
context_briefing={},
)
if get_agent_role(new_assignee) != "developer":
return Envelope.not_authorized(
message=f"{new_assignee!r} is not a developer",
remediate=(
"reassign hands work to a developer in your cell;"
" only dev slugs are valid"
),
context_briefing={},
)
if get_agent_team(new_assignee) != caller_team:
return Envelope.not_authorized(
message=f"{new_assignee!r} is not in your cell ({caller_team!r})",
remediate="reassign only to a developer in your own cell",
context_briefing={},
)
return None
async def reassign(
self, agent_id: UUID, task_id: UUID, new_assignee: str
) -> Envelope:
"""A cell PM hands a claimed/in_progress task to another dev in its cell.
Intra-cell only (see ``_validate_reassign``). The branch is keyed to the
task, not the agent, so it survives — the new developer continues the
work-in-progress and is respawned by the orchestrator.
"""
from roboco.seeds.initial_data import AGENT_UUIDS
t = await self.task.get(task_id)
briefing = await self._briefing_for(agent_id, task_id)
if t is None:
return await self._emit_rejection(
Envelope.not_found(message=f"task {task_id} not found"),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
agent = await self.task.agent_for(agent_id)
role_str = str(agent.role) if agent is not None else "cell_pm"
try:
role = spec_module.Role(role_str)
except ValueError:
return await self._emit_rejection(
Envelope.not_authorized(
message=f"unknown role '{role_str}'",
remediate="role is not declared in the lifecycle spec",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
spec_ctx = spec_module.Context(
actor_id=agent_id,
actor_slug=getattr(agent, "slug", None) if agent is not None else None,
original_developer_slug=_extract_original_developer(t),
)
decision = spec_module.can_invoke_intent(role, "reassign", t, spec_ctx)
if not decision.allowed:
return await self._emit_rejection(
Envelope.from_decision(decision, briefing=briefing).with_introspection(
task=t, role=role_str
),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
guard = self._validate_reassign(t, agent_id, new_assignee)
if guard is not None:
return await self._emit_rejection(
guard.with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
after = await self.task.reassign_active_claim(
task_id, UUID(AGENT_UUIDS[new_assignee])
)
if after is None:
return await self._emit_rejection(
Envelope.invalid_state(
message=f"cannot reassign from status {t.status}",
remediate="only a claimed / in_progress task can be reassigned",
context_briefing=briefing,
).with_introspection(task=t, role=role_str),
agent_id=agent_id,
task_id=task_id,
verb="reassign",
)
return Envelope.ok(
status=str(after.status),
task_id=str(task_id),
next=spec_module._INTENT_VERBS["reassign"].next_hint(after),
context_briefing=briefing,
).with_introspection(task=after, role=role_str)
async def resume(self, agent_id: UUID, task_id: UUID) -> Envelope:
"""Resume a paused task this agent owns; transitions paused → in_progress.
@@ -689,6 +689,11 @@ class ContentActions:
t = await self.task.get_journal_context_task_for_agent(agent_id)
if t is not None:
task_id = t.id
# A dependency block is a "wait silently" situation — never a CEO signal.
# An agent must not page the CEO to relax or escalate a task that is
# simply waiting on an unfinished upstream; that wait clears on its own.
if reject := await self._reject_ceo_dependency_notify(target, task_id):
return reject
await self.notifications.send_ack_notification(
from_agent=agent_id,
to_agent=target,
@@ -703,6 +708,51 @@ class ContentActions:
context_briefing={},
)
async def _reject_ceo_dependency_notify(
self, target: str, task_id: UUID | None
) -> Envelope | None:
"""Rejection envelope if this is a CEO notification about a dep block.
A dependency block clears when the upstream completes paging the CEO
about it is pure noise and burn. Returns None when the notification is
allowed (non-CEO target, no task, or no open dependency).
"""
from roboco.agents_config import is_ceo
if task_id is None or not is_ceo(target):
return None
dep_block = await self._dependency_block_reason(task_id)
if not dep_block:
return None
return Envelope.invalid_state(
message=f"cannot notify the CEO about a dependency block — {dep_block}",
remediate=(
"a dependency block clears automatically when the upstream task "
"completes — do not notify or escalate. Call i_am_idle() and "
"wait; the task resumes on its own."
),
context_briefing={},
)
async def _dependency_block_reason(self, task_id: UUID) -> str | None:
"""Reason string if ``task_id`` is waiting on an unfinished dependency.
Used to refuse CEO notifications about a dependency block: such a block
is resolved by the upstream completing, not by a human, so paging the
CEO is pure noise and burn.
"""
task = await self.task.get(task_id)
if task is None:
return None
dep_ids = list(task.dependency_ids or [])
if not dep_ids:
return None
unmet = await self.task.unmet_dependency_ids(dep_ids)
if unmet:
noun = "dependency" if len(unmet) == 1 else "dependencies"
return f"{len(unmet)} {noun} not yet completed"
return None
async def _is_caller_dependency(self, agent_id: UUID, task: Any) -> bool:
"""True when ``task`` is a dependency of a task the caller is assigned to.
+10 -5
View File
@@ -239,14 +239,19 @@ class Envelope:
"""
from roboco.foundation.policy import lifecycle as spec
self.current_state = str(getattr(task, "status", "") or "") or None
try:
self.current_state = str(getattr(task, "status", "") or "") or None
role_enum = spec.Role(role)
self.valid_next_verbs = spec.valid_next_verbs(role_enum, task)
except (ValueError, TypeError):
# Unknown role string OR task.status not a Status enum value
# (e.g. AsyncMock in tests, partial fixtures). Match legacy
# verb_gates semantics: best-effort, never raise.
except Exception:
# Introspection is best-effort and NEVER raises. Failure modes:
# an unknown role string (ValueError); a mock/partial task
# (TypeError); OR — critically, on an error path after a
# rolled-back async session — reading task.status hits an EXPIRED
# ORM attribute whose lazy reload fires outside the greenlet and
# raises sqlalchemy MissingGreenlet. Any of these must degrade to
# empty introspection rather than mask the real error this
# envelope is reporting. (current_state defaults to None.)
self.valid_next_verbs = []
return self
+13 -3
View File
@@ -831,11 +831,21 @@ class GitService(BaseService):
workspace, base_branch, default_branch, task_id
)
# Fast-forward the checked-out base to the freshly-fetched remote tip.
# A plain `git pull origin <base>` is fragile in automation: if the
# local base has diverged at all it aborts with exit 128 ("Need to
# specify how to reconcile divergent branches" / refusing to merge
# unrelated histories), which then blows up the whole claim. We only
# ever want the latest remote base before cutting a branch, so a local
# `merge --ff-only origin/<base>` is the right intent — and it uses the
# ref the scoped fetch above already updated (no second network call).
# check=False: a non-fast-forward (divergent local) or a base that
# isn't on the remote yet leaves the checked-out base as the branch
# point instead of aborting branch creation.
await self._run_git(
workspace,
["pull", "origin", base_branch],
token=project_token,
timeout=_network_git_timeout(),
["merge", "--ff-only", f"origin/{base_branch}"],
check=False,
)
# Idempotent branch creation: a prior attempt may have created the
# branch on disk but failed before the DB recorded branch_name (the
+10
View File
@@ -410,6 +410,12 @@ class MessagingService(BaseService):
)
self.session.add(session)
# Flush so session.id (a flush-time uuid4 default) is materialized BEFORE we
# link it on the group. active_session_id is a plain scalar FK with no
# relationship, so SQLAlchemy cannot defer-populate it — assigning session.id
# while it is still None persists active_session_id as NULL, and the group then
# opens a brand-new session on every post instead of reusing this one.
await self.session.flush()
# Update group
group.active_session_id = session.id
@@ -641,6 +647,10 @@ class MessagingService(BaseService):
status=SessionStatus.ACTIVE,
)
self.session.add(new_session)
# Flush so new_session.id is materialized before we link it on the group;
# assigning it pre-flush persists active_session_id as NULL (see
# create_session).
await self.session.flush()
group.active_session_id = new_session.id
group.total_sessions += 1
await self.session.flush()
+31
View File
@@ -5364,6 +5364,37 @@ class TaskService(BaseService):
)
return task
async def reassign_active_claim(
self, task_id: UUID, new_assignee: UUID
) -> TaskTable | None:
"""Hand an active (claimed/in_progress) task to a new claimant.
Distinct from ``reassign`` (review-state handoffs): this reseeds
``claimed_at`` / ``last_heartbeat_at`` / ``active_claimant_id`` so the
new claimant isn't immediately stale to the reaper (the prior dev that
prompted the reassignment often has a stale heartbeat). The branch is
keyed to the task, so the work-in-progress survives. Returns None if the
task is gone or no longer in an active dev-owned state.
"""
task = await self.get(task_id)
if task is None:
return None
if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS):
return None
now = datetime.now(UTC)
task.assigned_to = cast("Any", new_assignee)
task.claimed_by = cast("Any", new_assignee)
task.claimed_at = now
task.last_heartbeat_at = now
task.active_claimant_id = cast("Any", new_assignee)
await self.session.flush()
self.log.info(
"Active task reassigned to a fresh claimant",
task_id=str(task_id),
new_assignee=str(new_assignee),
)
return task
async def mark_agent_idle(self, agent_id: UUID) -> None:
"""Set agent.status = IDLE."""
result = await self.session.execute(