From 90732fd84078cb799259a8ccfa23c64c4c71976a Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Mon, 19 Oct 2026 14:12:01 +0200 Subject: [PATCH] [ticket-221] Defer human and skip operator tickets in Planfile next_ticket and next_tickets --- planfile/__init__.py | 38 +++++++++-- project/TICKETS.md | 2 + project/ticket-221/README.md | 27 ++++++++ project/ticket-221/intent.json | 82 +++++++++++++++++++++++ tests/test_next_ticket_autonomy_filter.py | 44 ++++++++++++ 5 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 project/ticket-221/README.md create mode 100644 project/ticket-221/intent.json diff --git a/planfile/__init__.py b/planfile/__init__.py index 1677ede..0c72797 100644 --- a/planfile/__init__.py +++ b/planfile/__init__.py @@ -206,11 +206,30 @@ def _autonomy_skip(ticket: "Ticket") -> str: return "autonomy-frontier" if "actor:human" in labels: return "actor:human" + executor_kind = ( + str(ticket.executor.kind or "").strip().lower() + if getattr(ticket, "executor", None) + else "" + ) + exec_queue = ( + str(ticket.execution.queue or "default").strip().lower() + if getattr(ticket, "execution", None) + else "default" + ) + if executor_kind == "human" and (exec_queue == "operator" or "operator" in labels): + return "actor:human" for label in labels: if label.startswith("waiting:") or label.startswith("needs-human"): return label return "" + @staticmethod + def _is_human_or_missing_executor(ticket: "Ticket") -> int: + """Return 0 for machine-runnable executors, 1 for human or unspecified executors.""" + executor = getattr(ticket, "executor", None) + kind = str(getattr(executor, "kind", None) or "").strip().lower() if executor else "" + return 1 if kind in {"", "human"} else 0 + @staticmethod def _autonomy_blocked(ticket: "Ticket") -> bool: """Boolean form of :meth:`_autonomy_skip` (frozen frontier / human / resource wait).""" @@ -248,7 +267,7 @@ def runnability_skip_reason( if exec_state not in self._RUNNABLE_EXEC_STATES: return f"exec_state:{exec_state}" autonomy = self._autonomy_skip(ticket) - if autonomy: + if autonomy and not (queue == "operator" and autonomy == "actor:human"): return autonomy if self._goal_frozen(ticket): return "goal-frozen" @@ -307,7 +326,7 @@ def runnable_report(self, sprint: str = "current", queue: str | None = None) -> ticket_by_id=ticket_by_id, ) (skipped.append({"id": ticket.id, "reason": reason}) if reason else servable.append(ticket)) - servable.sort(key=self._ticket_sort_key) + servable.sort(key=lambda t: (self._is_human_or_missing_executor(t), *self._ticket_sort_key(t))) return {"selected": servable[0].id if servable else None, "servable": [t.id for t in servable], "skipped": skipped} @@ -328,7 +347,11 @@ def next_ticket(self, sprint: str = "current", queue: str | None = None) -> Tick ticket_by_id=ticket_by_id, ) ) - return min(runnable, key=self._ticket_sort_key, default=None) + return min( + runnable, + key=lambda t: (self._is_human_or_missing_executor(t), *self._ticket_sort_key(t)), + default=None, + ) def next_tickets( self, @@ -370,7 +393,14 @@ def next_tickets( def sort_key(t: Ticket): base_key = self._ticket_sort_key(t) crit_weight = -weights.get(t.id, 0) - return (base_key[0], base_key[1], crit_weight, base_key[2], base_key[3]) + return ( + self._is_human_or_missing_executor(t), + base_key[0], + base_key[1], + crit_weight, + base_key[2], + base_key[3], + ) runnable.sort(key=sort_key) diff --git a/project/TICKETS.md b/project/TICKETS.md index 4c8e12c..4a32671 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -128,4 +128,6 @@ This file indexes governance tickets without taking ownership of | **ticket-216** | [`README.md`](./ticket-216/README.md) | - | - | - | - | - | | **ticket-217** | [`README.md`](./ticket-217/README.md) | - | - | - | - | - | | **ticket-218** | [`README.md`](./ticket-218/README.md) | - | - | - | - | - | +| **ticket-220** | [`README.md`](./ticket-220/README.md) | - | - | - | - | - | +| **ticket-221** | [`README.md`](./ticket-221/README.md) | - | - | - | - | - | diff --git a/project/ticket-221/README.md b/project/ticket-221/README.md new file mode 100644 index 0000000..4a76c73 --- /dev/null +++ b/project/ticket-221/README.md @@ -0,0 +1,27 @@ +# ticket-221: Defer human and skip operator tickets in Planfile next_ticket and next_tickets + +- **Status**: IN_PROGRESS +- **Workstream**: `application` +- **Classification**: `BUG` / `P1` / `health` +- **Agent**: `agy` + +## Problem Statement + +When Koru's native Planfile SDK fast path calls `Planfile.next_ticket()` or `Planfile.next_tickets()`: +1. Tickets with `executor.kind == "human"` and `execution.queue == "operator"` (such as `PLF-255` in `semcod/tagi` and `PLF-2674` in `semcod/koru`) were not skipped by `Planfile._autonomy_skip` when `queue=None`, stalling the queue in `waiting_input` instead of allowing `idle` discovery. +2. High-priority tickets without a machine executor (`executor=None` or `executor.kind="human"`, such as `PLF-17307` in `semcod/koru`) were returned ahead of machine-runnable tickets (`executor.kind="llm"`, such as `PLF-104..PLF-118`), halting the autonomous queue on `waiting_input` while runnable machine work remained. + +## Solution + +- Updated `Planfile._autonomy_skip` and `Planfile.runnability_skip_reason` in `planfile/__init__.py` to return `"actor:human"` for `executor.kind == "human"` tickets in the `operator` queue (or carrying the `operator` label) unless `queue == "operator"` is explicitly requested. +- Added `Planfile._is_human_or_missing_executor` and wired it into `Planfile.next_ticket`, `Planfile.next_tickets`, and `Planfile.runnable_report` so machine-runnable tickets (`0`) always precede human or unspecified executor tickets (`1`). + +## Acceptance Criteria + +- **AC-01**: `Planfile.next_ticket()` and `Planfile.next_tickets()` skip `operator` human tickets during default drain and rank machine-runnable tickets ahead of human/unspecified executor tickets. + +## Validation Evidence + +- `pytest -q tests/test_next_ticket_autonomy_filter.py tests/test_priority_weighting.py` (`14 passed`) +- `ruff check planfile/__init__.py tests/test_next_ticket_autonomy_filter.py` (`All checks passed!`) +- `./project/governance-check.sh` (`GOV-PASS`) diff --git a/project/ticket-221/intent.json b/project/ticket-221/intent.json new file mode 100644 index 0000000..98d70eb --- /dev/null +++ b/project/ticket-221/intent.json @@ -0,0 +1,82 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-221", + "summary": "Defer human and skip operator tickets in Planfile next_ticket and next_tickets", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-221/**", + "TODO.md", + "project/TICKETS.md", + "planfile/__init__.py", + "tests/test_next_ticket_autonomy_filter.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "7354b47dcb60a8f040c1811d3ec9e981fe0c61f5", + "targetBranch": "main", + "outcome": "Skip operator-queue human tickets during default autonomous queue drain and defer human/unspecified executor tickets behind machine-runnable tickets in Planfile.next_ticket and Planfile.next_tickets.", + "nonGoals": [ + "Do not alter _ticket_sort_key priority tuple order or explicit queue='operator' queries." + ], + "complexity": "S", + "estimatedMinutes": 10, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Skip operator-queue human tickets in Planfile._autonomy_skip unless queue=='operator', and rank machine-runnable executors ahead of human/unspecified executors in Planfile.next_ticket, Planfile.next_tickets, and Planfile.runnable_report.", + "components": [ + { + "name": "core-and-paths", + "paths": [ + "planfile/__init__.py" + ] + }, + { + "name": "tests", + "paths": [ + "tests/test_next_ticket_autonomy_filter.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert commit for ticket-221." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "pytest -q tests/test_next_ticket_autonomy_filter.py tests/test_priority_weighting.py", + "ruff check planfile/__init__.py tests/test_next_ticket_autonomy_filter.py", + "./project/governance-check.sh" + ], + "evidence": "project/ticket-221/README.md" + } + ] + } +} diff --git a/tests/test_next_ticket_autonomy_filter.py b/tests/test_next_ticket_autonomy_filter.py index 8625239..e69b758 100644 --- a/tests/test_next_ticket_autonomy_filter.py +++ b/tests/test_next_ticket_autonomy_filter.py @@ -133,3 +133,47 @@ def unexpected_lookup(_ticket_id): monkeypatch.setattr(pf, "get_ticket", unexpected_lookup) assert pf.next_ticket().id == "RUN" + + +def test_operator_human_ticket_skipped_in_default_autonomy_drain(monkeypatch): + monkeypatch.delenv("PLANFILE_NO_AUTONOMY_FILTER", raising=False) + monkeypatch.delenv("CURRENT_GOAL", raising=False) + op_ticket = _mk( + id="PLF-255", + name="Project discovery", + priority="high", + executor={"kind": "human", "mode": "interactive"}, + execution={"queue": "operator", "state": "ready"}, + ) + pf = Planfile.__new__(Planfile) + monkeypatch.setattr(pf, "list_tickets", lambda **kwargs: [op_ticket]) + monkeypatch.setattr(pf, "get_ticket", lambda _i: None) + + assert pf.runnability_skip_reason(op_ticket) == "actor:human" + assert pf.next_ticket() is None + assert pf.runnability_skip_reason(op_ticket, queue="operator") == "" + assert pf.next_ticket(queue="operator").id == "PLF-255" + + +def test_next_ticket_defers_human_and_unspecified_executor_behind_machine_work(monkeypatch): + monkeypatch.delenv("PLANFILE_NO_AUTONOMY_FILTER", raising=False) + monkeypatch.delenv("CURRENT_GOAL", raising=False) + human_bug = _mk( + id="PLF-17307", + name="Manual audit bug", + priority="high", + labels=["bug"], + ) + llm_task = _mk( + id="PLF-104", + name="Shotgun surgery refactor", + priority="normal", + executor={"kind": "llm", "mode": "automatic"}, + ) + pf = Planfile.__new__(Planfile) + monkeypatch.setattr(pf, "list_tickets", lambda **kwargs: [human_bug, llm_task]) + monkeypatch.setattr(pf, "get_ticket", lambda _i: None) + + assert pf.next_ticket().id == "PLF-104" + assert [t.id for t in pf.next_tickets(count=2, disjoint_files=False)] == ["PLF-104", "PLF-17307"] +