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:
1 parent
01f791a1f4
commit
c1310c3322
37 files changed
+1436
-80
No files matched your search
+121
-10
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user