Skip to content

Commit 073be70

Browse files
authored
Merge pull request #6022 from Jim-jimu/fix/explore-todo-readiness
fix(explore): exclude unready todos from dispatch suggestions
2 parents 065f22c + e1d28d6 commit 073be70

15 files changed

Lines changed: 366 additions & 82 deletions

‎.github/workflows/python-tests.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,10 @@ jobs:
423423
needs: [changes, chat-bundle]
424424
if: needs.changes.outputs.python_tests == 'true'
425425
runs-on: ubuntu-latest
426-
timeout-minutes: 45
426+
# All four shards were still progressing at 83-94% when the 45m job
427+
# deadline cancelled run 37916979857. Reserve setup/reporting headroom
428+
# without changing test-level deadlines, collection or worker count.
429+
timeout-minutes: 60
427430
strategy:
428431
fail-fast: false
429432
matrix:
@@ -472,7 +475,7 @@ jobs:
472475
# Without timing history least_duration alternates equal-weight tests.
473476
# Each runner retains the measured two-worker pool.
474477
run: >-
475-
python -m pytest -q -n 2 -m "not stage2c_e2e"
478+
python -m pytest -v -n 2 -m "not stage2c_e2e"
476479
--splits 4 --group ${{ matrix.shard }}
477480
--splitting-algorithm least_duration
478481
--durations=25 --durations-min=1

‎docs/development/testing-and-quality.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,9 @@ Lint, type checks, and the CLI budget run separately. The required `pytest`
555555
check rejects failed/skipped shards and missing coverage artifacts, then uses
556556
`coverage combine` to enforce the existing 19.6% floor on the union, not on
557557
individual shards. Relative coverage paths make reports portable across runners.
558+
Each shard has a 60-minute job ceiling for setup, the full partition and report
559+
upload; individual test deadlines stay unchanged. Verbose progress names failed
560+
cases before the suite finishes, so an interrupted run retains their identities.
558561
The reusable Sonar workflow consumes that same run's XML and never reruns
559562
pytest or reads cross-run artifacts. Missing Sonar tokens still skip analysis
560563
successfully; test jobs receive no Sonar secret. The trigger is the union of
@@ -565,11 +568,13 @@ Linux 全套测试分到四台 hosted runner,每台保留两个 xdist worker
565568
按完整 collection 分片;没有历史耗时时,等权测试交替分配。lint、类型检查和 CLI
566569
预算独立执行。必需的 `pytest` 汇总检查会拒绝失败/跳过的分片和缺失的 coverage,
567570
合并后再执行原有 19.6% 门槛;不要求单个分片达到全套覆盖率。coverage 使用相对路径,
571+
每个分片的作业上限为 60 分钟,覆盖环境准备、完整分片和报告上传;单项测试超时
572+
保持不变。详细进度在套件结束前就记录失败用例名称,运行中断后仍可定位这些用例。
568573
Sonar 只复用同一次 run 的 XML,不重复测试、不跨 run 取产物。缺少 token 仍成功跳过
569574
Sonar,测试 job 不接收 Sonar secret。触发范围取原有两套 workflow 的并集;纯前端
570575
PR 使用前述豁免,Sonar 配置变更仍全量运行,包括没有 token 的 fork。
571576

572-
Reproduce one shard locally with `uv run --extra test python -m pytest -q -n 2 --splits 4 --group 1
577+
Reproduce one shard locally with `uv run --extra test python -m pytest -v -n 2 --splits 4 --group 1
573578
--splitting-algorithm least_duration --cov=loopx`. Omit the split arguments to
574579
run the complete suite locally. 全量本地测试仍省略分片参数即可。
575580

‎examples/shared-goal-authority-e2e/mutants.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,8 @@ def apply(source: str) -> str:
287287
"if False:")),),
288288
"tests/control_plane/test_shadow_drain_adversarial.py::test_state_file_override_cannot_attribute_an_unbound_source_to_active_lineage"),
289289
Case("bootstrap_unregistered_root", ((COORDINATION + "runtime_shadow.ts", replacement(
290-
"if (registeredRoot !== request.runtime_root)",
291-
"if (false && registeredRoot !== request.runtime_root)")),),
290+
"if (!await sameSourceRuntimeRoot(registeredRoot, request.runtime_root))",
291+
"if (false)")),),
292292
"tests/control_plane/test_runtime_shadow_bounded_e2e.py::test_controller_cannot_bind_the_registered_source_to_an_override_runtime_root"),
293293
Case("bootstrap_unregistered_state", ((COORDINATION + "runtime_shadow.ts", replacement(
294294
"if (resolve(String(snapshot.state_path)) !== resolve(String(snapshot.registered_state_path)))",

‎loopx/capabilities/explore/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,20 @@ packet with:
398398
- the safety boundary that keeps the packet advisory rather than an
399399
replacement for `quota should-run`.
400400

401+
Both planners apply the existing Todo readiness rule before scheduling or
402+
bundling current work. An open Todo with `resume_when` is selectable only when
403+
its projected `resume_ready` is true. Unready Todos remain in rejected-candidate
404+
diagnostics with their condition, but consume no verification width or resource
405+
slot and carry no claim/lease suggestions. The bounded turn context preserves
406+
these diagnostics and reports omitted candidates; read the full plan for detail.
407+
If no branch is actionable, the plan asks the caller to inspect the conditions
408+
and replan instead of suggesting execution.
409+
410+
For `B.resume_when=todo_done:A`, handing A to another Agent does not release B;
411+
completing A does. Successor lineage alone is not a completion dependency. Future
412+
work remains visible for planning, and fresh quota, claim and lease checks still
413+
govern execution after any plan, handoff or readiness change.
414+
401415
An advancement todo may opt into typed result diagnostics by attaching one or
402416
more explicit Explore node ids:
403417

‎loopx/capabilities/explore/todo_branch_plan.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,16 +214,27 @@ def _claim_bucket(item: Mapping[str, Any], *, agent_id: str | None) -> int:
214214
return 2
215215

216216

217-
def _monitor_lane_exclusion(candidate: Mapping[str, Any]) -> dict[str, Any] | None:
218-
if candidate.get("task_class") != TODO_TASK_CLASS_MONITOR:
219-
return None
217+
def _candidate_readiness(item: Mapping[str, Any]) -> dict[str, Any]:
218+
# Reuse the canonical read model; a planner does not resolve dependencies
219+
# from ownership, lineage, or the other candidates selected in this batch.
220220
return {
221-
**candidate,
222-
"selection_status": "excluded_non_exploration_lane",
223-
"exclusion_reason": "continuous_monitor_does_not_consume_exploration_budget",
221+
"actionable_open": todo_item_is_actionable_open(dict(item)),
222+
**{key: item[key] for key in ("status", "resume_when", "resume_ready") if key in item},
224223
}
225224

226225

226+
def _execution_exclusion(candidate: Mapping[str, Any]) -> dict[str, Any] | None:
227+
if candidate.get("task_class") == TODO_TASK_CLASS_MONITOR:
228+
return {
229+
**candidate,
230+
"selection_status": "excluded_non_exploration_lane",
231+
"exclusion_reason": "continuous_monitor_does_not_consume_exploration_budget",
232+
}
233+
if not candidate["actionable_open"]:
234+
return {**candidate, "selection_status": "not_actionable_open"}
235+
return None
236+
237+
227238
def _lane_id(todo_id: str, *, index: int) -> str:
228239
return f"branch_{index}_{todo_id.removeprefix('todo_')}"
229240

@@ -366,6 +377,7 @@ def build_explore_todo_branch_plan(
366377
task_class = todo_item_task_class(dict(item))
367378
required_capabilities = _required_capabilities(item)
368379
candidate = {
380+
**_candidate_readiness(item),
369381
"todo_id": todo_id,
370382
"text": _compact_text(item.get("text") or item.get("title")),
371383
"priority": item.get("priority"),
@@ -407,15 +419,16 @@ def build_explore_todo_branch_plan(
407419
)
408420
)
409421

410-
monitor_lanes = [
422+
excluded_candidates = [
411423
exclusion
412424
for candidate in candidates
413-
if (exclusion := _monitor_lane_exclusion(candidate)) is not None
425+
if (exclusion := _execution_exclusion(candidate)) is not None
414426
]
415427
exploration_candidates = [
416428
candidate
417429
for candidate in candidates
418430
if candidate.get("task_class") != TODO_TASK_CLASS_MONITOR
431+
if candidate["actionable_open"]
419432
]
420433
schedulable = [
421434
candidate
@@ -552,7 +565,7 @@ def build_explore_todo_branch_plan(
552565
lane="todo_branch_plan",
553566
)
554567
if not invalidated:
555-
rejected = [*monitor_lanes, *dependency_rejections, *selection_rejections]
568+
rejected = [*excluded_candidates, *dependency_rejections, *selection_rejections]
556569
selected = valid_selected
557570
break
558571
new_invalidated_ids = {
@@ -562,7 +575,7 @@ def build_explore_todo_branch_plan(
562575
dependency_events.extend(events)
563576
invalidated_ids.update(new_invalidated_ids)
564577
if not new_invalidated_ids:
565-
rejected = [*monitor_lanes, *dependency_rejections, *selection_rejections]
578+
rejected = [*excluded_candidates, *dependency_rejections, *selection_rejections]
566579
selected = valid_selected
567580
break
568581

@@ -602,6 +615,7 @@ def build_explore_todo_branch_plan(
602615
"exploration_candidate_count": len(exploration_candidates),
603616
"selected_count": len(selected),
604617
"selected_branches": selected,
618+
"rejected_candidate_count": len(rejected),
605619
"rejected_candidates": rejected[: max(0, normalized_width * 3)],
606620
"resource_portfolio": resource_portfolio_with_selection(resource_portfolio, selected),
607621
"hazard_model": {
@@ -642,6 +656,9 @@ def build_explore_todo_branch_plan(
642656
"registered goal's spawn_policy (spawn_allowed, max_children) to receive "
643657
"suggested commands; execution stays in the normal LoopX lifecycle."
644658
if gate["state"] == GATE_STATE_ANALYSIS_ONLY
659+
else "No actionable branch was selected. Inspect rejected candidates and their "
660+
"resume conditions; replan when readiness changes."
661+
if not selected
645662
else "Run the primary branch normally; hand selected speculative branches to side "
646663
"agents only after claiming/lease commands are accepted and workspace guards pass."
647664
if len(selected) > 1

‎loopx/capabilities/explore/worker_branch_plan.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@
3737
_branch_confidence,
3838
_branch_expected_evidence_units,
3939
_branch_score,
40+
_candidate_readiness,
4041
_claim_command,
4142
_compact_text,
4243
_frontier_tokens,
4344
_lease_command,
44-
_monitor_lane_exclusion,
45+
_execution_exclusion,
4546
_required_capabilities,
4647
_required_write_scopes,
4748
_shared_dependency_capabilities,
@@ -396,6 +397,7 @@ def _todo_candidate(
396397
task_class = todo_item_task_class(dict(item))
397398
required_capabilities = _required_capabilities(item)
398399
candidate = {
400+
**_candidate_readiness(item),
399401
"todo_id": todo_id,
400402
"text": _compact_text(item.get("text") or item.get("title")),
401403
"priority": item.get("priority"),
@@ -640,21 +642,23 @@ def _build_worker_branch_candidates(
640642
)
641643
)
642644

643-
monitor_lanes = [
645+
excluded_candidates = [
644646
exclusion
645647
for candidate in todo_candidates
646-
if (exclusion := _monitor_lane_exclusion(candidate)) is not None
648+
if (exclusion := _execution_exclusion(candidate)) is not None
647649
]
648650
blocked = [
649651
{**candidate, "selection_status": "blocked_claimed_by_other"}
650652
for candidate in todo_candidates
651653
if candidate.get("task_class") != TODO_TASK_CLASS_MONITOR
654+
if candidate["actionable_open"]
652655
if candidate.get("claimed_by") and candidate.get("claimed_by") != normalized_agent
653656
]
654657
eligible = [
655658
candidate
656659
for candidate in todo_candidates
657660
if candidate.get("task_class") != TODO_TASK_CLASS_MONITOR
661+
if candidate["actionable_open"]
658662
if not (candidate.get("claimed_by") and candidate.get("claimed_by") != normalized_agent)
659663
]
660664

@@ -749,7 +753,7 @@ def _branch_sort_key(item: Mapping[str, Any]) -> tuple:
749753
)
750754

751755
branch_candidates.sort(key=_branch_sort_key)
752-
return branch_candidates, [*monitor_lanes, *blocked]
756+
return branch_candidates, [*excluded_candidates, *blocked]
753757

754758

755759
def _baseline_worker_lanes(branch_candidates: Sequence[Mapping[str, Any]], *, width: int) -> dict[str, Any]:
@@ -1250,6 +1254,9 @@ def build_explore_worker_branch_plan(
12501254
"registered goal's spawn_policy (spawn_allowed, max_children) to receive "
12511255
"suggested commands; execution stays in the normal LoopX lifecycle."
12521256
if gate["state"] == GATE_STATE_ANALYSIS_ONLY
1257+
else "No actionable worker branch was selected. Inspect rejected candidates and "
1258+
"their resume conditions; replan when readiness changes."
1259+
if not selected
12531260
else "Use quota should-run, then execute each selected worker branch through "
12541261
"normal LoopX claim, lease, todo execution, explore writeback, refresh-state, "
12551262
"and quota spend-slot."

‎loopx/control_plane/capabilities/explore_turn_context.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ function evidenceCount(audit: JsonObject, kind: string, detailCount: number): nu
2727
return Math.max(detailCount, total);
2828
}
2929
function branchContext(row: JsonObject): JsonObject {
30-
const result = compact(row, ["todo_id", "text", "branch_role", "score", "confidence"]);
30+
const result = compact(row, ["todo_id", "text", "branch_role", "score", "confidence",
31+
"selection_status", "status", "actionable_open", "resume_when", "resume_ready"]);
3132
if (row.typed_evidence_audit == null) return result;
3233
const audit = requireJsonObject(row.typed_evidence_audit, "Explore evidence audit");
3334
const nodes = rows(audit.nodes), findings = rows(audit.findings), edges = rows(audit.relevant_edges);
@@ -64,6 +65,7 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject {
6465
String(b.last_updated_at ?? "").localeCompare(String(a.last_updated_at ?? "")));
6566
const findings = rows(projection.findings);
6667
const branches = rows(plan.selected_branches);
68+
const rejected = rows(plan.rejected_candidates);
6769
const frontier = rows(projection.frontier);
6870
const selectedRefs = new Set(harness ? branches.slice(0, 3).flatMap(branch => {
6971
if (branch.typed_evidence_audit == null) return [];
@@ -155,6 +157,9 @@ export function projectExploreTurnContext(params: JsonObject): JsonObject {
155157
candidate_count: plan.candidate_count ?? 0,
156158
selected_branches: branches.slice(0, 3).map(branchContext),
157159
omitted_selected_branches: Math.max(0, branches.length - 3),
160+
rejected_candidates: rejected.slice(0, 3).map(branchContext),
161+
omitted_rejected_candidates: Math.max(0,
162+
requireInteger(plan.rejected_candidate_count ?? rejected.length, "rejected_candidate_count") - 3),
158163
frontier: frontier.slice(0, 3).map(node => compact(node, ["node_id", "title", "status", "summary"])),
159164
omitted_frontier: Math.max(0, frontier.length - 3),
160165
// Expand the same Todo audit shown above, not a different worker-lane plan.

‎tests/capabilities/test_explore_turn_context.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,34 @@ def test_turn_context_audit_count_compatibility(counts, omitted):
543543
assert len(result["findings"]) == 3
544544

545545

546+
def test_turn_context_preserves_wait_conditions_and_total_omissions():
547+
from loopx.capabilities.explore.todo_branch_plan import build_explore_todo_branch_plan
548+
549+
plan = build_explore_todo_branch_plan(
550+
goal_id="research", agent_id="worker", width=3,
551+
orchestration={"explore_harness": {"enabled": True}},
552+
todos=[{
553+
"todo_id": f"todo_waiting_{index}", "text": "Use the prepared artifact",
554+
"status": "open", "task_class": "advancement_task", "priority": "P0",
555+
"resume_when": "todo_done:todo_prepare", "resume_ready": False,
556+
} for index in range(12)],
557+
)
558+
packet = effect_runtime_result("explore.turn_context", {
559+
"goal_id": "research", "agent_id": "worker", "route": ["loopx"],
560+
"harness_gate": {"enabled": True}, "graph_enabled": False,
561+
"projection": {}, "plan": plan,
562+
})
563+
context = packet["harness"]
564+
assert context["selected_branches"] == []
565+
assert len(context["rejected_candidates"]) == 3
566+
assert context["omitted_rejected_candidates"] == 9
567+
for row in context["rejected_candidates"]:
568+
assert row["actionable_open"] is False
569+
assert row["resume_ready"] is False
570+
assert row["resume_when"] == "todo_done:todo_prepare"
571+
assert "todo-branch-plan" in context["plan_command"]
572+
573+
546574
@pytest.mark.parametrize("kind", ["findings", "edges"])
547575
def test_turn_context_rejects_negative_evidence_count(kind):
548576
with pytest.raises(EffectRuntimeRejected, match=f"Explore {kind} count must be nonnegative") as rejected:

‎tests/capabilities/test_periodic_report_native_successor.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _native_successor(
4141
"--delivery-workspace-path", str(project),
4242
"--agent-id", AGENT_ID, "--delivery-batch-scale", "implementation",
4343
"--delivery-outcome", "outcome_progress", "--no-global-sync",
44-
"--suppress-external-sinks",
44+
"--suppress-external-sinks", "--available-capability", "network",
4545
]
4646
closed = _run_cli([
4747
*base, "--classification", "fixture_stage_closed",
@@ -141,6 +141,7 @@ def test_native_successor_produces_only_an_enabled_bounded_intent(
141141
assert intent["requested_write_scope"] == []
142142
assert intent["payload"]["generation_authorized"] is False
143143
assert intent["payload"]["external_delivery_authorized"] is False
144+
assert intent["payload"]["available_capabilities"] == ["network"]
144145
stage = intent["payload"]["stage_completion"]
145146
assert stage["transition"] == "successor_frontier_settled"
146147
assert stage["acceptance"] == "validated"

0 commit comments

Comments
 (0)