feat(orchestrator): tasks tab with register, inbox and queue

Add a Tasks tab that lists the kvb-org-rasci register next to the inbox
and the heavy-job queue, in list, board and RASCI matrix views, each
task with its roles, WSJF, open work and the merge flow of its worktree.

- Task priority pins and WSJF estimates are signed with the operator's
  passkey and applied by the runner through rasci.py; the hub verifies
  the signature itself before it queues a job or logs the change, the
  runner verifies it again and runs only the checked argv, from /, with
  a pinned PATH and a 30 s process-group limit.
- The runner refuses task changes into a council that is a symlink, not
  owned by its uid or given by a relative path, and while the installed
  council.py, nofollow.py or rasci.py differ from the tree.
- Only tasks the hub can sign whole are editable (task slug and length).
- Page lookups keyed by register text use Object.hasOwn, so a status or
  party named like an Object.prototype member cannot break the views.
- The inbox screen renders through the tasks screen, so its task filter
  shows with "Wartet auf Operator" pressed.

Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox).

🎯 Fix: 93 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 88
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 23:43:36 +00:00
1 parent 01f791a1f4
commit c1310c3322
37 files changed
+1436 -80

No files matched your search

+121 -10
View File
@@ -6,28 +6,42 @@
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: the runner's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-orchestrator-approvals/runner.json.
--rasci: the installed register script, e.g.
~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional.
--council: the council directory holding rasci.json, the agents'
AGENTS_COUNCIL_ROOT; optional, task changes need both.
--once: handle the queued jobs once and exit.
Returns:
Runs as the operator, outside any sandbox, every operator command a
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
in the signed directory, each signature once and before its expiry.
Refuses every other job. Posts the last 200 output lines back while it
runs and the exit code at its end. The exit code is 0.
Runs every signed task change (priority pin or WSJF estimate) as
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the
argv shape itself, and kills its process group after 30 s. Refuses task
changes without --rasci and --council or into a council that is a
symlink or another uid's, and every other job. Posts the last 200 output lines
back while it runs and the exit code at its end. The exit code is 0.
"""
from __future__ import annotations
import argparse
import collections
import contextlib
import json
import os
import signal
import stat
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
from typing import NamedTuple
import hub_api
import signing
@@ -35,7 +49,16 @@ import verifier
LINES = 200
BEAT = 2.0
SAFE_PATH = "/usr/bin:/bin"
TASK_JOB = "task"
TASK_LIMIT = 30.0
REFUSED = "refused: no valid operator signature for this command"
UNREGISTERED = "refused: this runner was started without --rasci and --council; reinstall its unit with make orchestrator-runner"
class Register(NamedTuple):
rasci: Path
council: Path
def call(base: str, path: str, payload: dict | None) -> dict:
@@ -72,6 +95,43 @@ def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
return None
def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
"""Args:
job: the claimed task change with task, argv and proof.
trust: the pinned keys, the runner's replay store and the origin.
now: the current time in epoch seconds.
Returns:
Why the change must not run, or None when its argv has one of the
shapes signing.task_argv() allows and a pinned key signed it.
"""
try:
argv = signing.task_argv(job["task"], job["argv"])
verifier.check(job.get("proof"), signing.task_item(job["task"], argv), "", trust, now)
except (signing.Refused, OSError) as error:
return f"{REFUSED}: {error}"
return None
def council_refusal(council: Path) -> str | None:
"""Args:
council: the directory rasci.py writes the register into.
Returns:
Why no task change may run there, or None for a real directory of
the runner's own uid, given by an absolute path.
"""
if not council.is_absolute():
return f"refused: the council {council} is no absolute path"
try:
found = os.lstat(council)
except OSError as error:
return f"refused: the council {council} cannot be read: {error}"
if not stat.S_ISDIR(found.st_mode) or found.st_uid != os.geteuid():
return f"refused: the council {council} is a symlink, no directory or not owned by uid {os.geteuid()}"
return None
def execute(job: dict, report: Callable[[dict], None]) -> int:
"""Args:
job: the claimed and verified job with command and directory.
@@ -81,12 +141,46 @@ def execute(job: dict, report: Callable[[dict], None]) -> int:
Returns:
The exit code.
"""
return spawn(["bash", "-c", job["command"]], job["directory"], None, report)
def change(job: dict, register: Register, report: Callable[[dict], None]) -> int:
"""Args:
job: the claimed and verified task change with task and argv.
register: the register script and the council it writes.
report: called with {"output", "code", "refused"}.
Returns:
The exit code of rasci.py, run from / on the argv task_argv() checked.
Raises:
signing.Refused for an argv of any other shape.
"""
argv = signing.task_argv(job["task"], job["argv"])
env = {"PATH": SAFE_PATH, "HOME": str(Path.home()), "AGENTS_COUNCIL_ROOT": str(register.council)}
return spawn([sys.executable, "-I", "-B", str(register.rasci), *argv], "/", env, report, TASK_LIMIT)
def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int:
"""Args:
argv: the program and its arguments, run without a shell.
cwd: the directory it runs in.
env: its whole environment, or None for the runner's own.
report: called with {"output", "code", "refused"}; code None while
the program runs.
limit: seconds until its whole process group is killed, or None to
let it run.
Returns:
The exit code, -9 after the kill.
"""
tail: collections.deque = collections.deque(maxlen=LINES)
lock = threading.Lock()
try:
process = subprocess.Popen(
["bash", "-c", job["command"]],
cwd=job["directory"],
argv,
cwd=cwd,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -99,11 +193,19 @@ def execute(job: dict, report: Callable[[dict], None]) -> int:
return 127
reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True)
reader.start()
deadline = None if limit is None else time.monotonic() + limit
while True:
try:
code = process.wait(BEAT)
code = process.wait(BEAT if deadline is None else max(0.0, min(BEAT, deadline - time.monotonic())))
break
except subprocess.TimeoutExpired:
if deadline is not None and time.monotonic() >= deadline:
with contextlib.suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
code = process.wait()
with lock:
tail.append(f"killed after {limit:g} s\n")
break
with lock:
output = "".join(tail)
report({"output": output, "code": None, "refused": False})
@@ -122,18 +224,24 @@ def sender(base: str, job_id: str) -> Callable[[dict], None]:
return send
def cycle(base: str, trust: verifier.Trust) -> None:
def cycle(base: str, trust: verifier.Trust, register: Register | None) -> None:
for job in call(base, "/api/runner/jobs", None)["jobs"]:
try:
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
except urllib.error.HTTPError:
continue
send = sender(base, claimed["id"])
reason = refusal(claimed, trust, time.time())
if reason is None:
execute(claimed, send)
task = claimed.get("kind") == TASK_JOB
if not task:
reason = refusal(claimed, trust, time.time())
else:
reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time())
if reason is not None:
send({"output": reason, "code": None, "refused": True})
elif task:
change(claimed, register, send)
else:
execute(claimed, send)
def main() -> int:
@@ -142,13 +250,16 @@ def main() -> int:
parser.add_argument("--interval", type=float, required=True)
parser.add_argument("--approvers", type=Path, required=True)
parser.add_argument("--replay", type=Path, required=True)
parser.add_argument("--rasci", type=Path)
parser.add_argument("--council", type=Path)
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
base = hub_api.base_url(args.port)
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None
while True:
try:
cycle(base, trust)
cycle(base, trust, register)
except (OSError, ValueError, KeyError, TypeError):
pass
if args.once:
+35
View File
@@ -22,6 +22,11 @@ WORK = re.compile(r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{1,300}")
HOLDER = re.compile(r"[^\x00-\x1f\x7f]{0,200}")
OFFICE_ACTIONS = ("appoint", "takeover", "recall")
OFFICE_KEYS = ("action", "office", "holder", "session", "work")
TASK = re.compile(r"[a-z0-9][a-z0-9._-]{0,99}(?:/[a-z0-9][a-z0-9._-]{0,99}){0,7}")
PRIO = re.compile(r"[1-9][0-9]{0,2}")
FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
ESTIMATOR = "Operator"
COMMANDS = "operator/"
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
WARNINGS = (
@@ -138,6 +143,36 @@ def stream_item(origins: list[str]) -> dict:
return {"kind": "streams", "origins": origins}
def task_item(task: str, argv: list[str]) -> dict:
return {"kind": "task", "id": task, "argv": argv}
def task_argv(task: object, argv: object) -> list[str]:
"""Args:
task: the register task the change is for, a rasci.py slug outside
operator/.
argv: the rasci.py arguments, one of `set <task> --prio <1-999>`,
`estimate <task> Operator --value <fib> --complexity <fib>` or
`estimate <task> Operator --drop`.
Returns:
argv, the hub and the runner check it the same way.
Raises:
Refused for every other task or argv.
"""
if not isinstance(task, str) or not TASK.fullmatch(task) or task.startswith(COMMANDS):
raise Refused("the task is no register task slug outside operator/")
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
raise Refused("the argv is no list of texts")
rest = argv[3:]
pinned = argv[:3] == ["set", task, "--prio"] and len(argv) == 4 and bool(PRIO.fullmatch(argv[3]))
estimated = len(rest) == 4 and rest[0] == "--value" and rest[1] in FIBONACCI and rest[2] == "--complexity" and rest[3] in FIBONACCI
if pinned or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])):
return argv
raise Refused("the argv is no task priority or WSJF estimate change of this task")
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}