fix(extensions): exclude unadmitted team waits from active budget (#6298)

* fix(extensions): exclude unadmitted team waits from active budget

* docs(extensions): explain frozen team input reservation
This commit is contained in:
Daoyuan Li 2026-10-05 01:10:07 -07:00 committed by GitHub
parent 2970aa4323
commit 4d9f1d7aa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 84 additions and 1 deletions

View File

@ -258,6 +258,77 @@ async def test_ambiguous_admission_retries_same_key_and_frozen_input(plugin, mon
assert len(runs.starts) == 1
async def queue_active_budget_jobs(actions, runs):
jobs = []
for index in range(5):
owner = "bob" if index == 4 else "alice"
team = await actions["create"](
{"request_id": f"team-{index}", "name": "Release", "goal": "Check readiness", "members": [{"name": "Research", "agent": "researcher"}, {"name": "Review", "agent": "reviewer"}]},
context(runs, owner),
)
for member in team["members"]:
if len(jobs) == 9:
break
payload = {"team_id": team["id"], "member_id": member["id"], "text": "Check", "request_id": f"job-{len(jobs)}"}
sent = await actions["send"](payload, context(runs, owner))
jobs.append({**sent, "owner": owner, "team_id": team["id"], "member_id": member["id"], "thread_id": member["thread_id"]})
return jobs
@pytest.mark.asyncio
@pytest.mark.parametrize("wait_kind", ["interrupt", "clarification"])
async def test_unadmitted_conversation_waits_do_not_consume_other_teams_active_budget(plugin, wait_kind):
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
_, actions, service = plugin
runs = Runs()
jobs = await queue_active_budget_jobs(actions, runs)
for job in jobs[:8]:
state = runs.threads[job["thread_id"]]
if wait_kind == "interrupt":
state.update(next=["tools"], interrupts=[{"value": "Approve?"}])
else:
request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "outside", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None)
command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("tool handler should be intercepted"))
state["values"]["messages"].extend(m.model_dump() for m in command.update["messages"])
# A later request on the same waiting conversation must still remain queued.
blocked = jobs[0]
await actions["send"]({"team_id": blocked["team_id"], "member_id": blocked["member_id"], "text": "Later", "request_id": "later"}, context(runs))
for _ in range(2):
await service.tick()
assert [thread for thread, _ in runs.starts] == [jobs[-1]["thread_id"]]
for job in jobs[:8]:
stored = await service.db("get", job["owner"], job["team_id"])
assert all(item["status"] == "queued" and item["input"] is None for item in stored["jobs"])
healthy = await actions["get"]({"team_id": jobs[-1]["team_id"]}, context(runs, "bob"))
assert healthy["jobs"][0]["status"] == "running"
@pytest.mark.asyncio
@pytest.mark.parametrize("lost_ack", [False, True])
async def test_active_budget_retains_real_and_ambiguous_admissions(plugin, monkeypatch, lost_ack):
_, actions, service = plugin
runs = Runs()
jobs = await queue_active_budget_jobs(actions, runs)
if lost_ack:
start = runs.start
async def uncertain(**kwargs):
await start(**kwargs)
raise TimeoutError("admitted but acknowledgement lost")
monkeypatch.setattr(runs, "start", uncertain)
for _ in range(2):
await service.tick()
assert len(runs.starts) == 8
assert jobs[-1]["id"] not in runs.runs
for job in jobs[:8]:
stored = await service.db("get", job["owner"], job["team_id"])
assert all(item["input"] is not None for item in stored["jobs"])
healthy = await service.db("get", "bob", jobs[-1]["team_id"])
assert healthy["jobs"][0]["status"] == "queued" and healthy["jobs"][0]["input"] is None
@pytest.mark.asyncio
async def test_cancel_stops_a_running_request_and_pending_delete_is_rejected(plugin):
_, actions, service = plugin

View File

@ -15,6 +15,9 @@ Unknown admission outcomes must retain ownership of the pending job. Never
automatically approve interruptions, infer success from a terminal run alone, or
serialize a host capability. Only an authenticated action or native mention
rebinds after restart.
Release a new job's temporary active-budget reservation only if its input remains
unfrozen after advancement; conversation waits must not block unrelated teams.
Keep frozen inputs charged even when admission acknowledgement is lost.
Reconnect ensures every member thread before persisting readiness, serialized
with deletion and task updates. Count only requests toward the 100-request cap;

View File

@ -99,6 +99,8 @@ own teams. Sharing a host conversation does **not** share its team's private dat
fairness and finer-grained locking are outside this example's current scope.
Each conversation is serialized, including result
receipts. A busy host conversation stays queued without cancelling its run.
Queued jobs blocked by an unanswered question or interrupt do not consume the
active-job budget before admission; an unknown admission outcome still does.
- Each owner can have 20 teams. A team accepts at most 100 requests, with separate
space for up to 100 result receipts (200 total job entries); a handoff chain
accepts at most 12 requests. Start a new user request for another chain, or a

View File

@ -392,7 +392,8 @@ class Teams:
if job["status"] in TERMINAL or job["thread_id"] in occupied:
continue
occupied.add(job["thread_id"])
if job["status"] == "queued" and job["input"] is None:
reserved = job["status"] == "queued" and job["input"] is None
if reserved:
if active >= 8:
continue
active += 1
@ -414,6 +415,11 @@ class Teams:
# Disconnect rather than releasing the member's slot.
self.handles.pop((owner, team_id), None)
break
finally:
# A conversation wait did not attempt admission. Frozen
# inputs retain the slot, including unknown outcomes.
if reserved and job["input"] is None:
active -= 1
async def save_job(self, owner, team_id, job):
def update(team):
@ -443,6 +449,7 @@ class Teams:
"Use the exact registered tool names in your tool list. Your final answer is automatically returned to the requester; "
"do not send a separate peer request just to report completion. Do not wait in a polling loop.\n"
) + json.dumps({"request": job["text"], "your_member_id": job["member_id"], "source_thread": job["source"], "kind": job["kind"], **shared(team)}, ensure_ascii=False)
# Freeze and persist input before start(): tick releases reservations only while input is None.
job["input"] = {"messages": [{"role": "user", "id": "team-" + job["id"], "content": content}]}
await self.save_job(owner, team["id"], job)
if job["run_id"] is None: