feat(orchestrator): S5 Tasks with choices, Gantt, estimates and burn charts
Requirement 001 phase 6, S5: - Tasks tab: every pending ask, rule request, operator row and choice card shows under "waits on Operator"; a choice request goes out on a fixed channel button, post and call with an agent multi-select, and a click on an option sends a passkey-signed answer the agent checks with operator_message. The unsigned /api/decisions/<id>/choose route is gone, and a decision, choice answer or choice recipient whose name holds "*" is refused, so a signed answer never reaches every agent. - Gantt view: one bar per task from start to end (running to now), one arrow per explicit, branch-base and queue-order edge, hatched forecast bars (always, points only, real only) from the median duration or points times median hours per point. - rasci.py set --after/--drop-after stores task dependencies and refuses unknown tasks and cycles. - Estimation toggle (No estimate default, Story points) is stored in the hub settings; burndown and burnup per whole register, list filter or one agent or line, counting tasks or summing complexity with the unestimated tasks as their own line. - New skill kvb-org-queue-estimate: "Request estimates" (story points mode, passkey-signed board action) or the skill sends exactly one newsletter listing every unestimated task with its RASCI parties; a party name that is no plain name shows as "unnamed party". - Business value per task or in bulk is stored only with a verified passkey assertion over the exact ids and values; WSJF uses it instead of the median. The task list sorts by every decision-50 mode both ways. - e2e: the walk's pending ask lives an hour instead of 60 s, and the CDP and BiDi drivers retry removing the browser profile while the browser exits (ENOTEMPTY). Evidence: make test 709 OK, ruff and markdownlint clean (/tmp/s5-make-test-fix-2.log); e2e 26 OK in Chromium and Firefox (/tmp/s5-e2e-fix-2.log); MEDUSA 50 files, an independent skeptic rejected four holes (unsigned choose route, unsigned estimates request, free-text party names, "*" recipients incl. normalizer bypasses), all fixed and re-verified, the 8 remaining pattern hits accepted, rescan 0 open. 🎯 Fix: 92 % 💥 Break: 4 % 💸 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
5c8b69d53d
commit
d9eb121d12
49 files changed
+2162
-148
No files matched your search
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from hub_support import PLAN
|
||||
|
||||
HOUR = 3600
|
||||
NOW = 100 * HOUR
|
||||
PLAN_PROBE = """
|
||||
const P = require(process.argv[1]);
|
||||
const H = 3600;
|
||||
const now = 100 * H;
|
||||
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, points: null, wsjf: null, value: null, worker: "-", title: "-", roles: {}, ...fields});
|
||||
const rows = [
|
||||
row("work/a", {status: "done", ended: true, start: 80 * H, end: 82 * H, points: 2, worker: "Skills"}),
|
||||
row("work/b", {status: "done", ended: true, start: 84 * H, end: 88 * H, points: 2, roles: {Stage: "R"}}),
|
||||
row("work/c", {status: "done", ended: true, start: 90 * H, end: 91 * H}),
|
||||
row("work/run", {status: "running", start: 95 * H, points: 5, worker: "Stage"}),
|
||||
row("work/next", {points: 3}),
|
||||
row("work/tail", {}),
|
||||
row("work/stop", {status: "stopped", ended: true, start: 85 * H, end: 86 * H, points: 1}),
|
||||
row("queue/x", {kind: "queue", status: "awaiting"}),
|
||||
row("operator/push", {kind: "command", status: "done", ended: true, end: 70 * H}),
|
||||
];
|
||||
const edges = [
|
||||
{from: "work/run", to: "work/next", kind: "after"},
|
||||
{from: "work/next", to: "work/tail", kind: "after"},
|
||||
{from: "work/c", to: "work/tail", kind: "base"},
|
||||
{from: "work/run", to: "queue/x", kind: "queue"},
|
||||
{from: "work/gone", to: "work/tail", kind: "base"},
|
||||
];
|
||||
const loop = [row("loop/a", {}), row("loop/b", {})];
|
||||
const looped = [{from: "loop/a", to: "loop/b", kind: "base"}, {from: "loop/b", to: "loop/a", kind: "queue"}];
|
||||
const lay = (estimation, forecast) => P.bars(rows, edges, {now, estimation, forecast}).map((bar) => [bar.task, bar.start / H, bar.end / H, bar.forecast, bar.dot]);
|
||||
const sorts = [
|
||||
row("t1", {wsjf: {score: 2}, value: 8, points: 3, status: "running", start: 5, worker: "Bravo", title: "Zulu"}),
|
||||
row("t2", {status: "done", start: 3, end: 9}),
|
||||
row("t3", {wsjf: {score: 5}, value: 3, points: 1, worker: "alpha", title: "Alpha"}),
|
||||
row("t4", {wsjf: {score: 2}, value: 13, points: 8, status: "blocked", start: 1, end: 4, worker: "Charlie", title: "mike"}),
|
||||
];
|
||||
const context = {filtered: [rows[0], rows[7]], since: 90 * H, until: 99 * H, now, holders: {"identity/oidc": "Stage"}};
|
||||
const scope = (choice) => {
|
||||
const found = P.scoped(rows, choice, context);
|
||||
return {tasks: found.rows.map((entry) => entry.task), from: found.from / H, to: found.to / H};
|
||||
};
|
||||
const series = (estimation) => P.burn(P.scoped(rows, "all", context).rows, {estimation, from: 70 * H, to: now}).points.map((point) => [point.t / H, point.scope, point.done, point.remaining, point.unestimated]);
|
||||
console.log(JSON.stringify({
|
||||
options: [P.FORECASTS, P.ESTIMATIONS, P.SORTS.map(([id]) => id), P.SERIES],
|
||||
medians: P.medians(rows),
|
||||
median: [P.median([]), P.median([3, 1, 2]), P.median([4, 1, 3, 2])],
|
||||
bars: {noneAlways: lay("none", "always"), pointsAlways: lay("points", "always"), pointsOnly: lay("points", "points"), noneOnly: lay("none", "points"), real: lay("points", "real")},
|
||||
loop: P.bars(loop, looped, {now, estimation: "none", forecast: "always"}).map((bar) => bar.task),
|
||||
empty: P.bars([row("work/new", {}), row("work/gone", {status: "done", ended: true})], [], {now, estimation: "none", forecast: "always"}),
|
||||
scopes: {all: scope("all"), filter: scope("filter"), agent: scope("agent:Stage"), line: scope("line:identity/oidc"), none: scope("agent:Nobody")},
|
||||
burn: {none: series("none"), points: series("points")},
|
||||
sorted: Object.fromEntries(P.SORTS.map(([id]) => [id, [P.sortRows(sorts, id, false).map((entry) => entry.task), P.sortRows(sorts, id, true).map((entry) => entry.task)]])),
|
||||
unknown: P.sortRows(sorts, "nope", false).map((entry) => entry.task),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
class TestPlanModule(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
done = subprocess.run(["node", "-e", PLAN_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_the_selectors_offer_the_forecasts_estimations_sort_modes_and_series(self):
|
||||
forecasts, estimations, sorts, series = self.data["options"]
|
||||
self.assertEqual(forecasts, [["always", "always"], ["points", "points only"], ["real", "real only"]])
|
||||
self.assertEqual(estimations, [["none", "No estimate"], ["points", "Story points"]])
|
||||
self.assertEqual(sorts, ["prio", "wsjf", "value", "complexity", "status", "start", "end", "agent", "title"])
|
||||
self.assertEqual(series, ["scope", "done", "remaining", "unestimated"])
|
||||
|
||||
def test_medians_take_done_tasks_only_and_the_points_of_those_with_points(self):
|
||||
self.assertEqual(self.data["medians"], {"duration": 2 * HOUR, "perPoint": 1.5 * HOUR})
|
||||
self.assertEqual(self.data["median"], [None, 2, 2.5])
|
||||
|
||||
def test_without_estimate_a_forecast_is_as_long_as_the_median_duration_behind_its_predecessors(self):
|
||||
self.assertEqual(self.data["bars"]["noneAlways"], [
|
||||
["work/a", 80, 82, False, False],
|
||||
["work/b", 84, 88, False, False],
|
||||
["work/c", 90, 91, False, False],
|
||||
["work/run", 95, 100, False, False],
|
||||
["work/next", 100, 102, True, False],
|
||||
["work/tail", 102, 104, True, False],
|
||||
["work/stop", 85, 86, False, False],
|
||||
["queue/x", 100, 102, True, False],
|
||||
["operator/push", 70, 70, False, True],
|
||||
])
|
||||
|
||||
def test_with_story_points_a_forecast_is_points_times_the_median_hours_per_point(self):
|
||||
bars = {task: rest for task, *rest in self.data["bars"]["pointsAlways"]}
|
||||
self.assertEqual(bars["work/next"], [100, 104.5, True, False])
|
||||
self.assertEqual(bars["work/tail"], [104.5, 106.5, True, False])
|
||||
|
||||
def test_points_only_draws_a_dot_behind_the_predecessors_of_a_task_without_estimate(self):
|
||||
only = {task: rest for task, *rest in self.data["bars"]["pointsOnly"]}
|
||||
self.assertEqual((only["work/next"], only["work/tail"], only["queue/x"]), ([100, 104.5, True, False], [104.5, 104.5, True, True], [100, 100, True, True]))
|
||||
none = {task: rest for task, *rest in self.data["bars"]["noneOnly"]}
|
||||
self.assertEqual((none["work/next"], none["work/tail"]), ([100, 100, True, True], [100, 100, True, True]))
|
||||
|
||||
def test_real_only_draws_no_forecast(self):
|
||||
self.assertEqual([task for task, *_ in self.data["bars"]["real"]], ["work/a", "work/b", "work/c", "work/run", "work/stop", "operator/push"])
|
||||
self.assertFalse(any(bar[3] for bar in self.data["bars"]["real"]))
|
||||
|
||||
def test_a_cycle_still_draws_every_task_no_done_task_means_a_dot_and_an_ended_task_without_times_no_bar(self):
|
||||
self.assertEqual(self.data["loop"], ["loop/a", "loop/b"])
|
||||
self.assertEqual(self.data["empty"], [{"task": "work/new", "start": NOW, "end": NOW, "forecast": True, "dot": True}])
|
||||
|
||||
def test_the_scopes_are_the_whole_register_the_filter_in_its_window_and_one_agent_or_line(self):
|
||||
work = ["work/a", "work/b", "work/c", "work/run", "work/next", "work/tail", "work/stop"]
|
||||
scopes = self.data["scopes"]
|
||||
self.assertEqual(scopes["all"], {"tasks": work, "from": 80, "to": 100})
|
||||
self.assertEqual(scopes["filter"], {"tasks": ["work/a"], "from": 90, "to": 99})
|
||||
self.assertEqual(scopes["agent"], {"tasks": ["work/b", "work/run"], "from": 84, "to": 100})
|
||||
self.assertEqual(scopes["line"], scopes["agent"])
|
||||
self.assertEqual(scopes["none"], {"tasks": [], "from": 76, "to": 100})
|
||||
|
||||
def test_without_estimate_the_burn_counts_tasks(self):
|
||||
self.assertEqual(self.data["burn"]["none"], [
|
||||
[70, 2, 0, 2, 0], [80, 3, 0, 3, 0], [82, 3, 1, 2, 0], [84, 4, 1, 3, 0], [85, 5, 1, 4, 0], [86, 4, 1, 3, 0],
|
||||
[88, 4, 2, 2, 0], [90, 5, 2, 3, 0], [91, 5, 3, 2, 0], [95, 6, 3, 3, 0], [100, 6, 3, 3, 0],
|
||||
])
|
||||
|
||||
def test_with_story_points_the_burn_sums_complexity_and_counts_the_unestimated_apart(self):
|
||||
burn = self.data["burn"]["points"]
|
||||
self.assertEqual(burn[0], [70, 3, 0, 3, 1])
|
||||
self.assertEqual(burn[-1], [100, 12, 4, 8, 1])
|
||||
self.assertEqual([point[4] for point in burn], [1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1])
|
||||
|
||||
def test_the_list_sorts_by_every_mode_in_both_directions_with_missing_keys_last(self):
|
||||
self.assertEqual(self.data["sorted"], {
|
||||
"prio": [["t1", "t2", "t3", "t4"], ["t4", "t3", "t2", "t1"]],
|
||||
"wsjf": [["t1", "t4", "t3", "t2"], ["t3", "t1", "t4", "t2"]],
|
||||
"value": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
|
||||
"complexity": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
|
||||
"status": [["t3", "t1", "t4", "t2"], ["t2", "t4", "t1", "t3"]],
|
||||
"start": [["t4", "t2", "t1", "t3"], ["t1", "t2", "t4", "t3"]],
|
||||
"end": [["t4", "t2", "t1", "t3"], ["t2", "t4", "t1", "t3"]],
|
||||
"agent": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
|
||||
"title": [["t3", "t4", "t2", "t1"], ["t1", "t2", "t4", "t3"]],
|
||||
})
|
||||
self.assertEqual(self.data["unknown"], ["t1", "t2", "t3", "t4"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user