Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions planfile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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}

Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
27 changes: 27 additions & 0 deletions project/ticket-221/README.md
Original file line number Diff line number Diff line change
@@ -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`)
82 changes: 82 additions & 0 deletions project/ticket-221/intent.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
44 changes: 44 additions & 0 deletions tests/test_next_ticket_autonomy_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Loading