Skip to content

Commit 4b5aee6

Browse files
jackwildmangithub-actions[bot]
authored andcommitted
feat: expose active_workers from semaphore for accurate idle researcher count (#4644)
## Summary - **Expose semaphore entry counts** as `active_workers` (per-task) and `user_active_workers` (per-user) on the `TaskStatusResponse` API, giving an accurate view of real worker concurrency - **Fix idle researcher calculation** — the old `idle = pool_size - running` was broken for most operation types because artifact RUNNING status doesn't track actual semaphore-held concurrency: - **Classify**: `running` counted all rows in active batches (e.g. 30), but only 3 semaphore slots used - **Forecast**: all rows marked RUNNING upfront before agents even started - **Dedupe**: artifacts never marked RUNNING during execution; `running ≈ 0` while workers are active - **New formula**: `idle = pool_size - (user_active_workers ?? running)` — falls back to `running` for backward compat - **Remove forecast upfront RUNNING hack** — the block that marked all row artifacts RUNNING before research agents were queued is no longer needed since `active_workers` from the semaphore shows real concurrency ## Changes (14 files) **Engine (4 files)** - `semaphore.py` — add `get_task_entry_count()` method + `get_task_active_workers()` convenience function - `results.py` — add `active_workers` and `user_active_workers` fields to `TaskStatusResponse` - `handlers/tasks.py` — populate new fields from semaphore in `get_task_status` - `forecast.py` — remove upfront RUNNING artifact status hack **MCP (1 file)** - `tool_helpers.py` — add `active_workers` / `user_active_workers` computed properties on `TaskState`, include in `progress_message()` text **Frontend (9 files)** - `types.ts` — add fields to `TaskStatusResponse` interface - `artifact-data.ts` — parse `active_workers` and `user_active_workers` from MCP progress text - `useEveryrowPolling.ts` — thread new fields through the hook - `StreamStatsBar.tsx`, `LiveAnimation.tsx`, `LiveUtilityAnimation.tsx`, `ResearcherStreamVizPane.tsx` — use `userActiveWorkers` prop for idle calc - `TeamToolCall.tsx`, `TaskGroup.tsx` — use parsed `userActiveWorkers` for idle calc ## Test plan - [x] TypeScript compiles clean (`tsc --noEmit`) - [x] Python syntax valid for all modified files - [x] Pre-commit hooks pass (format, lint, typecheck for engine, MCP, and frontend) - [ ] Engine API tests (need env vars — verify in CI) - [ ] E2E: `pnpm cypress:run:replay` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Sourced from commit 879e62bc2bd40d7862e9356cd3ba281d9a4a2008
1 parent e880dfc commit 4b5aee6

File tree

2 files changed

+97
-1
lines changed

2 files changed

+97
-1
lines changed

futuresearch-mcp/src/futuresearch_mcp/tool_helpers.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,21 @@ def total(self) -> int:
395395
p = self._response.progress
396396
return p.total if p else 0
397397

398+
@computed_field
399+
@property
400+
def pool_size(self) -> int | None:
401+
return self._response.additional_properties.get("pool_size")
402+
403+
@computed_field
404+
@property
405+
def active_workers(self) -> int | None:
406+
return self._response.additional_properties.get("active_workers")
407+
408+
@computed_field
409+
@property
410+
def user_active_workers(self) -> int | None:
411+
return self._response.additional_properties.get("user_active_workers")
412+
398413
@computed_field
399414
@property
400415
def artifact_id(self) -> str:
@@ -462,9 +477,22 @@ def progress_message(
462477
return f"Task {self.status.value}. Report the error to the user."
463478

464479
fail_part = f", {self.failed} failed" if self.failed else ""
480+
pool_part = (
481+
f", pool_size {self.pool_size}" if self.pool_size is not None else ""
482+
)
483+
aw_part = (
484+
f", active_workers {self.active_workers}"
485+
if self.active_workers is not None
486+
else ""
487+
)
488+
uaw_part = (
489+
f", user_active_workers {self.user_active_workers}"
490+
if self.user_active_workers is not None
491+
else ""
492+
)
465493
cursor_arg = f", cursor='{cursor}'" if cursor else ""
466494
msg = dedent(f"""\
467-
Running: {self.completed}/{self.total} complete, {self.running} running{fail_part} ({self.elapsed_s}s elapsed)""")
495+
Running: {self.completed}/{self.total} complete, {self.running} running{fail_part}{pool_part}{aw_part}{uaw_part} ({self.elapsed_s}s elapsed)""")
468496

469497
if summaries:
470498
msg += "\n\nAgent activity:" + _format_summary_lines(summaries)

futuresearch-mcp/tests/test_tool_helpers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def _make_status_response(
2525
progress: TaskProgressInfo | None = None,
2626
created_at: datetime.datetime | None = None,
2727
updated_at: datetime.datetime | None = None,
28+
additional_properties: dict | None = None,
2829
):
2930
"""Build a mock TaskStatusResponse."""
3031
resp = MagicMock()
@@ -35,6 +36,7 @@ def _make_status_response(
3536
resp.error = error
3637
resp.created_at = created_at
3738
resp.updated_at = updated_at
39+
resp.additional_properties = additional_properties or {}
3840

3941
if progress is None:
4042
p = MagicMock()
@@ -160,3 +162,69 @@ def test_mixed_with_and_without_row_index(self):
160162
result = _format_summary_lines(summaries)
161163
assert "[Row 3] Has index" in result
162164
assert "No index" in result
165+
166+
167+
class TestTaskStateActiveWorkers:
168+
def test_active_workers_from_additional_properties(self):
169+
resp = _make_status_response(
170+
additional_properties={"active_workers": 7, "user_active_workers": 12},
171+
)
172+
ts = TaskState(resp)
173+
assert ts.active_workers == 7
174+
assert ts.user_active_workers == 12
175+
176+
def test_active_workers_none_when_absent(self):
177+
resp = _make_status_response(additional_properties={})
178+
ts = TaskState(resp)
179+
assert ts.active_workers is None
180+
assert ts.user_active_workers is None
181+
182+
def test_pool_size_still_works(self):
183+
resp = _make_status_response(
184+
additional_properties={"pool_size": 30, "active_workers": 5},
185+
)
186+
ts = TaskState(resp)
187+
assert ts.pool_size == 30
188+
assert ts.active_workers == 5
189+
190+
191+
class TestProgressMessageActiveWorkers:
192+
def test_running_message_includes_active_workers(self):
193+
p = MagicMock()
194+
p.completed = 10
195+
p.failed = 1
196+
p.running = 5
197+
p.total = 50
198+
resp = _make_status_response(
199+
status=TaskStatus.RUNNING,
200+
progress=p,
201+
created_at=datetime.datetime.now(datetime.UTC),
202+
additional_properties={
203+
"pool_size": 30,
204+
"active_workers": 8,
205+
"user_active_workers": 15,
206+
},
207+
)
208+
ts = TaskState(resp)
209+
msg = ts.progress_message("task-aw")
210+
assert "active_workers 8" in msg
211+
assert "user_active_workers 15" in msg
212+
assert "pool_size 30" in msg
213+
214+
def test_running_message_omits_active_workers_when_absent(self):
215+
p = MagicMock()
216+
p.completed = 10
217+
p.failed = 0
218+
p.running = 5
219+
p.total = 50
220+
resp = _make_status_response(
221+
status=TaskStatus.RUNNING,
222+
progress=p,
223+
created_at=datetime.datetime.now(datetime.UTC),
224+
additional_properties={"pool_size": 30},
225+
)
226+
ts = TaskState(resp)
227+
msg = ts.progress_message("task-no-aw")
228+
assert "active_workers" not in msg
229+
assert "user_active_workers" not in msg
230+
assert "pool_size 30" in msg

0 commit comments

Comments
 (0)