hubkit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hubcore/__init__.py +1 -0
- hubcore/cli.py +273 -0
- hubcore/compat.py +187 -0
- hubcore/config.py +107 -0
- hubcore/data/hub/LICENSE +202 -0
- hubcore/data/hub/SKILL.md +125 -0
- hubcore/data/hub/config.example.toml +84 -0
- hubcore/data/hub/docs/architecture.md +133 -0
- hubcore/data/hub/docs/commands.md +66 -0
- hubcore/data/hub/docs/configuration.md +151 -0
- hubcore/data/hub/docs/troubleshooting.md +21 -0
- hubcore/data/hub/intents.example.toml +74 -0
- hubcore/data/hub/references/design-sources.md +14 -0
- hubcore/data/hub/references/research.md +49 -0
- hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
- hubcore/data/hub/scripts/hubcore/cli.py +273 -0
- hubcore/data/hub/scripts/hubcore/compat.py +187 -0
- hubcore/data/hub/scripts/hubcore/config.py +107 -0
- hubcore/data/hub/scripts/hubcore/gate.py +145 -0
- hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
- hubcore/data/hub/scripts/hubcore/install.py +66 -0
- hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
- hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
- hubcore/data/hub/scripts/hubcore/orders.py +508 -0
- hubcore/data/hub/scripts/hubcore/redact.py +89 -0
- hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
- hubcore/data/hub/scripts/hubcore/slow.py +531 -0
- hubcore/data/hub/scripts/hubcore/status.py +535 -0
- hubcore/data/hub/scripts/hubcore/store.py +172 -0
- hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
- hubcore/data/hub/scripts/hubkit +37 -0
- hubcore/data/hub/scripts/learn.py +84 -0
- hubcore/data/hub-mep/SKILL.md +35 -0
- hubcore/data/hubkit-setup/SKILL.md +50 -0
- hubcore/gate.py +145 -0
- hubcore/hooks.py +326 -0
- hubcore/install.py +66 -0
- hubcore/learnings.py +232 -0
- hubcore/lessons.py +168 -0
- hubcore/orders.py +508 -0
- hubcore/redact.py +89 -0
- hubcore/routed.py +1169 -0
- hubcore/slow.py +531 -0
- hubcore/status.py +535 -0
- hubcore/store.py +172 -0
- hubcore/watcher.py +167 -0
- hubkit_cli-0.1.0.dist-info/METADATA +195 -0
- hubkit_cli-0.1.0.dist-info/RECORD +51 -0
- hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
- hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hubkit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Job records, estimates and the slowness check. Internal to the hub: nothing here is a user-facing report.
|
|
2
|
+
|
|
3
|
+
One record per live job (an order or a routed item): the job, its current status, when it started and when that
|
|
4
|
+
status began, the estimate, the holder, and that it is not done yet. Every tick rebuilds the records and checks them;
|
|
5
|
+
a job past its estimate (or, with no estimate, past its usual time) is SLOW, and a SLOW job with an estimate on
|
|
6
|
+
record blocks the stop gate until the hub addresses it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import statistics
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import config, hooks, routed, store
|
|
24
|
+
from .orders import Orders
|
|
25
|
+
from .status import work_load, work_save
|
|
26
|
+
|
|
27
|
+
ESTIMATE = re.compile(r"(?im)^\s*estimate:\s*(\d{1,4})\s*m(?:in)?\b")
|
|
28
|
+
AGENT_ID = re.compile(r"^[0-9a-f]{12,}$")
|
|
29
|
+
BAD = {
|
|
30
|
+
"FAILURE",
|
|
31
|
+
"ERROR",
|
|
32
|
+
"TIMED_OUT",
|
|
33
|
+
"CANCELLED",
|
|
34
|
+
"ACTION_REQUIRED",
|
|
35
|
+
"STARTUP_FAILURE",
|
|
36
|
+
}
|
|
37
|
+
RUNNING = {"IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "EXPECTED"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_estimate(text: str) -> int | None:
|
|
41
|
+
"""Minutes from an `Estimate: <n>m` line of a brief, else None."""
|
|
42
|
+
m = ESTIMATE.search(text)
|
|
43
|
+
return int(m[1]) if m else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def step_of(item: dict) -> str:
|
|
47
|
+
"""The step a routed item is in, by what it was asked to do."""
|
|
48
|
+
return (
|
|
49
|
+
"review" if item.get("review") else "fix push" if item.get("pushes") else "work"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---- baseline: the usual duration per step, by median ----
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def history(cfg: dict) -> dict[str, list[float]]:
|
|
57
|
+
"""Minutes taken per step key, from every archived routed item and closed order."""
|
|
58
|
+
out: dict[str, list[float]] = defaultdict(list)
|
|
59
|
+
for f in (store.state(cfg) / "archive").glob("archive_*.jsonl"):
|
|
60
|
+
for r in store.read_rows(f)[0]:
|
|
61
|
+
end = r.get("ts") or 0
|
|
62
|
+
if r.get("type") == "work" and r.get("kind") == "routed":
|
|
63
|
+
start = r.get("routed_at") or r.get("added") or end
|
|
64
|
+
if end > start:
|
|
65
|
+
out[step_of(r)].append((end - start) / 60)
|
|
66
|
+
if r.get("reported") and end > r["reported"]:
|
|
67
|
+
out["hub pickup"].append((end - r["reported"]) / 60)
|
|
68
|
+
elif (
|
|
69
|
+
r.get("type") == "order"
|
|
70
|
+
and r.get("outcome") == "closed"
|
|
71
|
+
and end > (r.get("opened") or end)
|
|
72
|
+
):
|
|
73
|
+
out["order:" + str(r.get("kind"))].append((end - r["opened"]) / 60)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def usual(
|
|
78
|
+
cfg: dict, hist: dict[str, list[float]], key: str, fallback: float
|
|
79
|
+
) -> tuple[float, int]:
|
|
80
|
+
"""(median minutes, samples). Too little history: the configured stall limit and the sample count it rests on."""
|
|
81
|
+
xs = hist.get(key, [])
|
|
82
|
+
if len(xs) >= int(cfg["slow"]["min_samples"]):
|
|
83
|
+
return round(statistics.median(xs), 1), len(xs)
|
|
84
|
+
return fallback, len(xs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def item_default(cfg: dict, item: dict) -> tuple[float, str]:
|
|
88
|
+
"""Default estimate for a routed item and its basis, used when the brief has no Estimate line."""
|
|
89
|
+
mins, n = usual(cfg, history(cfg), step_of(item), routed.limit(cfg, item))
|
|
90
|
+
return mins, (
|
|
91
|
+
f"median of {n}"
|
|
92
|
+
if n >= int(cfg["slow"]["min_samples"])
|
|
93
|
+
else f"stall limit, {n} samples"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def order_default(cfg: dict, orders: Orders, o: dict) -> tuple[float, str]:
|
|
98
|
+
"""Default estimate for an order and its basis."""
|
|
99
|
+
mins, n = usual(
|
|
100
|
+
cfg, history(cfg), "order:" + o["kind"], float(cfg["slow"]["order_default_min"])
|
|
101
|
+
)
|
|
102
|
+
return mins, (
|
|
103
|
+
f"median of {n}"
|
|
104
|
+
if n >= int(cfg["slow"]["min_samples"])
|
|
105
|
+
else f"configured default, {n} samples"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---- why: real signals only, never a transcript's text ----
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _epoch(iso: str) -> float:
|
|
113
|
+
return datetime.fromisoformat(iso).timestamp()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _tail(path: str, kb: int) -> list[str]:
|
|
117
|
+
size = os.path.getsize(path)
|
|
118
|
+
off = max(0, size - kb * 1024)
|
|
119
|
+
with open(path, "rb") as fh:
|
|
120
|
+
fh.seek(off)
|
|
121
|
+
lines = fh.read().decode("utf-8", "replace").splitlines()
|
|
122
|
+
return lines[1:] if off else lines
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _blocks(o: dict) -> list[dict]:
|
|
126
|
+
c = (o.get("message") or {}).get("content")
|
|
127
|
+
return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def activity(path: str, kb: int) -> tuple[dict[str, tuple[str, float]], int]:
|
|
131
|
+
"""(tool uses started and not yet answered: id -> (tool name, start epoch), unread queued messages).
|
|
132
|
+
Reads tool names, ids, times and queue operations only."""
|
|
133
|
+
open_tools: dict[str, tuple[str, float]] = {}
|
|
134
|
+
queued = 0
|
|
135
|
+
for line in _tail(path, kb):
|
|
136
|
+
if "tool_use" not in line and "queue-operation" not in line:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
o = json.loads(line)
|
|
140
|
+
if o.get("type") == "queue-operation":
|
|
141
|
+
queued += {"enqueue": 1, "dequeue": -1, "remove": -1}.get(
|
|
142
|
+
str(o.get("operation")), 0
|
|
143
|
+
)
|
|
144
|
+
elif o.get("type") == "assistant":
|
|
145
|
+
for b in _blocks(o):
|
|
146
|
+
if b.get("type") == "tool_use":
|
|
147
|
+
open_tools[str(b["id"])] = (
|
|
148
|
+
str(b["name"]),
|
|
149
|
+
_epoch(o["timestamp"]),
|
|
150
|
+
)
|
|
151
|
+
elif o.get("type") == "user":
|
|
152
|
+
for b in _blocks(o):
|
|
153
|
+
if b.get("type") == "tool_result":
|
|
154
|
+
open_tools.pop(str(b.get("tool_use_id")), None)
|
|
155
|
+
except ValueError, KeyError, TypeError:
|
|
156
|
+
continue
|
|
157
|
+
return open_tools, max(queued, 0)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def holder_session(cfg: dict, owner: str) -> dict | None:
|
|
161
|
+
"""The live session file of a peer by name, or of the hub (owner "hub")."""
|
|
162
|
+
if owner == "hub":
|
|
163
|
+
b = hooks.read_bind(cfg)
|
|
164
|
+
return hooks.session_file(cfg, int(b["pid"])) if hooks.bind_alive(b) else None
|
|
165
|
+
for f in glob.glob(str(config.sessions_dir(cfg) / "*.json")):
|
|
166
|
+
try:
|
|
167
|
+
s = json.loads(Path(f).read_text(encoding="utf-8"))
|
|
168
|
+
except OSError, ValueError:
|
|
169
|
+
continue
|
|
170
|
+
if s.get("name") == owner and hooks.proc_start(int(s.get("pid", 0))) == str(
|
|
171
|
+
s.get("procStart")
|
|
172
|
+
):
|
|
173
|
+
return s
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def holder_transcript(cfg: dict, owner: str, sess: dict | None) -> str | None:
|
|
178
|
+
"""Transcript file of the holder's session, or of a background agent."""
|
|
179
|
+
if sess:
|
|
180
|
+
return routed.transcript_of(cfg, str(sess["sessionId"]))
|
|
181
|
+
if AGENT_ID.match(owner):
|
|
182
|
+
for pat in routed.opts(cfg)["activity_globs"]:
|
|
183
|
+
hit = sorted(
|
|
184
|
+
glob.glob(os.path.expanduser(pat.format(id=owner))),
|
|
185
|
+
key=os.path.getmtime,
|
|
186
|
+
)
|
|
187
|
+
if hit:
|
|
188
|
+
return hit[-1]
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def ci_state(cfg: dict, url: str, timeout: float) -> str | None:
|
|
193
|
+
"""failing, pending or passing from the PR's checks; None when gh cannot say."""
|
|
194
|
+
exe = routed._gh(cfg)
|
|
195
|
+
if exe is None or not url.startswith("http"):
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
r = subprocess.run(
|
|
199
|
+
[exe, "pr", "view", url, "--json", "statusCheckRollup"],
|
|
200
|
+
capture_output=True,
|
|
201
|
+
encoding="utf-8", errors="replace",
|
|
202
|
+
timeout=timeout,
|
|
203
|
+
check=False,
|
|
204
|
+
)
|
|
205
|
+
checks = json.loads(r.stdout)["statusCheckRollup"]
|
|
206
|
+
except OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError:
|
|
207
|
+
return None
|
|
208
|
+
if not checks:
|
|
209
|
+
return None
|
|
210
|
+
concl = [str(c.get("conclusion") or c.get("state") or "").upper() for c in checks]
|
|
211
|
+
stat = [str(c.get("status") or "").upper() for c in checks]
|
|
212
|
+
if any(x in BAD for x in concl):
|
|
213
|
+
return "failing"
|
|
214
|
+
return (
|
|
215
|
+
"pending"
|
|
216
|
+
if any(s in RUNNING for s in stat) or any(x in RUNNING or not x for x in concl)
|
|
217
|
+
else "passing"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def why(
|
|
222
|
+
cfg: dict, owner: str, item: dict | None, now: float, deep: bool
|
|
223
|
+
) -> tuple[str, str]:
|
|
224
|
+
"""(text, signature) for a slow job. The signature has no durations, so it changes only when the cause does."""
|
|
225
|
+
s = cfg["slow"]
|
|
226
|
+
facts: list[str] = [] # signals that explain a delay
|
|
227
|
+
sig: list[str] = []
|
|
228
|
+
ctx = ""
|
|
229
|
+
if owner == "unknown":
|
|
230
|
+
return "unknown (no routed item is linked to this job)", "nolink"
|
|
231
|
+
sess = holder_session(cfg, owner)
|
|
232
|
+
if sess:
|
|
233
|
+
age = int((now - float(sess.get("statusUpdatedAt", 0)) / 1000) / 60)
|
|
234
|
+
ctx = f"session {sess.get('status', '?')} {age}m"
|
|
235
|
+
if sess.get("waitingFor"):
|
|
236
|
+
facts.append("waiting for " + store.safe(str(sess["waitingFor"]), 40))
|
|
237
|
+
sig.append("waitingFor")
|
|
238
|
+
elif owner != "hub" and not AGENT_ID.match(owner):
|
|
239
|
+
facts.append("no live session of that name")
|
|
240
|
+
sig.append("nosession")
|
|
241
|
+
elif owner == "hub":
|
|
242
|
+
facts.append("hub session not live")
|
|
243
|
+
sig.append("nohub")
|
|
244
|
+
path = holder_transcript(cfg, owner, sess)
|
|
245
|
+
if path:
|
|
246
|
+
try:
|
|
247
|
+
tools, unread = activity(path, int(s["scan_kb"]))
|
|
248
|
+
mt = os.path.getmtime(path)
|
|
249
|
+
except OSError:
|
|
250
|
+
tools, unread, mt = {}, 0, now
|
|
251
|
+
if tools:
|
|
252
|
+
name, t0 = min(tools.values(), key=lambda v: v[1])
|
|
253
|
+
mins = int((now - t0) / 60)
|
|
254
|
+
if mins >= int(s["tool_min"]):
|
|
255
|
+
facts.append(
|
|
256
|
+
f"{name} running {mins}m"
|
|
257
|
+
if len(tools) == 1
|
|
258
|
+
else f"{len(tools)} tools running, oldest {name} {mins}m"
|
|
259
|
+
)
|
|
260
|
+
sig.append("tool:" + name)
|
|
261
|
+
if unread:
|
|
262
|
+
facts.append(f"{unread} message{'s' if unread > 1 else ''} unread")
|
|
263
|
+
sig.append(f"unread:{unread}")
|
|
264
|
+
if AGENT_ID.match(owner) and now - mt > 120:
|
|
265
|
+
facts.append(f"transcript quiet {int((now - mt) / 60)}m")
|
|
266
|
+
sig.append("quiet")
|
|
267
|
+
if item:
|
|
268
|
+
if any(
|
|
269
|
+
e.get("kind") == "head_moved" and e["t"] >= (item.get("routed_at") or 0)
|
|
270
|
+
for e in item.get("events", [])
|
|
271
|
+
):
|
|
272
|
+
facts.append("PR head moved")
|
|
273
|
+
sig.append("head")
|
|
274
|
+
if deep and item.get("prs"):
|
|
275
|
+
ci = ci_state(cfg, item["prs"][0], float(routed.opts(cfg)["gh_timeout_s"]))
|
|
276
|
+
if ci in ("failing", "pending"):
|
|
277
|
+
facts.append(f"CI {ci}")
|
|
278
|
+
sig.append("ci:" + ci)
|
|
279
|
+
text = "; ".join(facts) if facts else "unknown" + (f" ({ctx})" if ctx else "")
|
|
280
|
+
return text, hashlib.sha1("|".join(sig).encode()).hexdigest()[:6]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---- the job records ----
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _addressed_after(its: list[dict], t: float) -> bool:
|
|
287
|
+
return any(a >= t for i in its for a in i.get("addressed", []))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _linked(cfg: dict, o: dict, live: list[dict]) -> list[dict]:
|
|
291
|
+
"""Routed items an order is tied to: a live item id named in the order's words or move notes (`W52`), or a PR
|
|
292
|
+
the order and the item both name (same repo and number). Nothing else links them."""
|
|
293
|
+
text = " ".join([o["quote"], *[n for _, n in o["moves"]]])
|
|
294
|
+
ids = set(re.findall(r"\bW\d+\b", text))
|
|
295
|
+
prs = [*o["refs"], *routed.refs(cfg, text, None)]
|
|
296
|
+
return [i for i in live if i["id"] in ids or routed.overlaps([*i["prs"], *i.get("queue", [])], prs)]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _owner_job(cfg: dict, item: dict, hist: dict, now: float, live_ids: set[str]) -> dict:
|
|
300
|
+
"""The record of one routed item."""
|
|
301
|
+
reported = item.get("reported") or 0
|
|
302
|
+
on_hub = bool(reported) and reported > (item.get("routed_at") or 0)
|
|
303
|
+
step = "hub pickup" if on_hub else step_of(item)
|
|
304
|
+
since = reported if on_hub else routed.started_at(cfg, item)
|
|
305
|
+
started = routed.started_at(cfg, item)
|
|
306
|
+
explicit = (not on_hub) and item.get("estimate_min") is not None
|
|
307
|
+
if explicit:
|
|
308
|
+
est, basis = float(item["estimate_min"]), (
|
|
309
|
+
"defaulted" if item.get("estimate_defaulted") else "estimate"
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
est, n = usual(cfg, hist, step, routed.limit(cfg, item))
|
|
313
|
+
basis = (
|
|
314
|
+
"usual" if n >= int(cfg["slow"]["min_samples"]) else f"limit, {n} samples"
|
|
315
|
+
)
|
|
316
|
+
clock = since if on_hub else started
|
|
317
|
+
thresh = clock + est * 60 * (1 + (0 if on_hub else len(item.get("addressed", []))))
|
|
318
|
+
return {
|
|
319
|
+
"id": item["id"],
|
|
320
|
+
"type": "routed",
|
|
321
|
+
"job": item["title"][:50],
|
|
322
|
+
"status": step,
|
|
323
|
+
"started": started,
|
|
324
|
+
"since": since,
|
|
325
|
+
"estimate": est,
|
|
326
|
+
"basis": basis,
|
|
327
|
+
"holder": "hub" if on_hub else item["owner"],
|
|
328
|
+
"done": False,
|
|
329
|
+
"over": item.get("blocked_on") not in live_ids and now > thresh and (now - clock) > float(cfg["slow"]["floor_min"]) * 60,
|
|
330
|
+
"gate": explicit,
|
|
331
|
+
"owner": item["owner"],
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _order_job(
|
|
336
|
+
cfg: dict, orders: Orders, o: dict, live: list[dict], hist: dict, now: float
|
|
337
|
+
) -> dict | None:
|
|
338
|
+
if o["state"] not in ("open", "held"):
|
|
339
|
+
return None
|
|
340
|
+
if o["state"] == "held":
|
|
341
|
+
return {
|
|
342
|
+
"id": o["id"],
|
|
343
|
+
"type": "order",
|
|
344
|
+
"job": o["kind"],
|
|
345
|
+
"status": "held by user",
|
|
346
|
+
"started": o["opened"],
|
|
347
|
+
"since": o["opened"],
|
|
348
|
+
"estimate": None,
|
|
349
|
+
"basis": "",
|
|
350
|
+
"holder": "user",
|
|
351
|
+
"done": False,
|
|
352
|
+
"over": False,
|
|
353
|
+
"gate": False,
|
|
354
|
+
"owner": "user",
|
|
355
|
+
}
|
|
356
|
+
link = _linked(cfg, o, live)
|
|
357
|
+
holder = "/".join(dict.fromkeys(i["owner"] for i in link)) if link else "unknown"
|
|
358
|
+
explicit = o.get("estimate_min") is not None
|
|
359
|
+
if explicit:
|
|
360
|
+
est = float(o["estimate_min"])
|
|
361
|
+
basis = "defaulted" if o["estimates"][-1]["source"] == "default" else "estimate"
|
|
362
|
+
else:
|
|
363
|
+
est, basis = order_default(cfg, orders, o)[0], "usual"
|
|
364
|
+
thresh = o["opened"] + est * 60
|
|
365
|
+
last = max([o["opened"], *[t for t, _ in o["moves"]]])
|
|
366
|
+
note = o["moves"][-1][1][:40] if o["moves"] else "no move yet"
|
|
367
|
+
return {
|
|
368
|
+
"id": o["id"],
|
|
369
|
+
"type": "order",
|
|
370
|
+
"job": o["kind"],
|
|
371
|
+
"status": f"open, {note}",
|
|
372
|
+
"started": o["opened"],
|
|
373
|
+
"since": last,
|
|
374
|
+
"estimate": est,
|
|
375
|
+
"basis": basis,
|
|
376
|
+
"holder": holder,
|
|
377
|
+
"done": False,
|
|
378
|
+
"over": now > thresh and not _addressed_after(link, thresh),
|
|
379
|
+
"gate": explicit,
|
|
380
|
+
"owner": holder,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def jobs(cfg: dict, orders: Orders, now: float, deep: bool = False) -> list[dict]:
|
|
385
|
+
"""One record per live order and routed item; an overrun job also carries why, sig and its NEXT."""
|
|
386
|
+
hist = history(cfg)
|
|
387
|
+
live = routed.items(cfg)
|
|
388
|
+
out = [
|
|
389
|
+
j for o in orders.live() if (j := _order_job(cfg, orders, o, live, hist, now))
|
|
390
|
+
]
|
|
391
|
+
out += [_owner_job(cfg, i, hist, now, {x['id'] for x in live}) for i in live]
|
|
392
|
+
by_id = {i["id"]: i for i in live}
|
|
393
|
+
for j in out:
|
|
394
|
+
if j["over"]:
|
|
395
|
+
j["why"], j["sig"] = why(cfg, j["owner"], by_id.get(j["id"]), now, deep)
|
|
396
|
+
j["next"] = next_action(cfg, j, by_id.get(j["id"]), now)
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def next_action(cfg: dict, job: dict, item: dict | None, now: float) -> str:
|
|
401
|
+
"""The one thing the hub does about an overrun, using the existing rule on re-briefing a reporting owner."""
|
|
402
|
+
est = f"or hubkit work estimate {job['id']} --minutes N --why \"<reason>\" (N above the {int((now - job['started']) / 60)}m spent)"
|
|
403
|
+
if job["holder"] == "unknown":
|
|
404
|
+
return f"find the holder and name its item id (W<n>) in an order move note, or act on it, {est}"
|
|
405
|
+
if job["holder"] == "hub":
|
|
406
|
+
return f"act on it now, {est}"
|
|
407
|
+
o = job["owner"]
|
|
408
|
+
heard = (item or {}).get("last_heard")
|
|
409
|
+
limit = routed.limit(cfg, item) if item else float(routed.opts(cfg)["stall_min"])
|
|
410
|
+
if heard and now - heard < limit * 60:
|
|
411
|
+
return f"SendMessage {o} for an ETA (it reported {int((now - heard) / 60)} min ago, so do not re-brief it), {est}"
|
|
412
|
+
return f"SendMessage {o} for a one-line state, or re-brief to another owner, {est}"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def line(job: dict, now: float) -> str:
|
|
416
|
+
"""The SLOW line for an overrun job."""
|
|
417
|
+
mins = (
|
|
418
|
+
int((now - job["since"]) / 60)
|
|
419
|
+
if job["status"] == "hub pickup"
|
|
420
|
+
else int((now - job["started"]) / 60)
|
|
421
|
+
)
|
|
422
|
+
word = "estimate" if job["basis"] in ("estimate", "defaulted") else "usual"
|
|
423
|
+
return (
|
|
424
|
+
f"SLOW {job['id']} {job['status']} {mins}m, {word} {job['estimate']:g}m ({job['basis']}); holder {job['holder']}; "
|
|
425
|
+
f"why: {job['why']}. NEXT: {job['next']}"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def alerts(cfg: dict, orders: Orders, now: float) -> list[dict]:
|
|
430
|
+
"""Tick alerts: one per overrun job, keyed by the cause so a repeat is back-off and a new cause is a new alert."""
|
|
431
|
+
return [
|
|
432
|
+
{"key": f"{j['id']}:slow:{j['sig']}", "msg": line(j, now)}
|
|
433
|
+
for j in jobs(cfg, orders, now, deep=True)
|
|
434
|
+
if j["over"]
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def gate_lines(cfg: dict, now: float) -> list[str]:
|
|
439
|
+
"""Stop-gate reasons: overrun jobs that carry an estimate on record and are not addressed."""
|
|
440
|
+
return [
|
|
441
|
+
"hubkit OVERRUN: " + line(j, now)
|
|
442
|
+
for j in jobs(cfg, Orders(cfg), now)
|
|
443
|
+
if j["over"] and j["gate"]
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def block_lines(st_jobs: list[dict], now: float) -> list[str]:
|
|
448
|
+
"""One short line per live job for the status block (hub context)."""
|
|
449
|
+
out = []
|
|
450
|
+
for j in st_jobs:
|
|
451
|
+
t = int((now - j["since"]) / 60)
|
|
452
|
+
est = f"/{j['estimate']:g}m" if j.get("estimate") is not None else ""
|
|
453
|
+
s = f"job {j['id']} {j['status'][:28]} {t}m{est} holder {j['holder']}"
|
|
454
|
+
if j.get("over"):
|
|
455
|
+
s += f" SLOW: {j.get('why', '')[:60]}"
|
|
456
|
+
out.append(s)
|
|
457
|
+
return out
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ---- re-estimate: recorded beside the old one, never over it ----
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def re_estimate(
|
|
464
|
+
cfg: dict, jid: str, minutes: int, reason: str, now: float | None = None
|
|
465
|
+
) -> str:
|
|
466
|
+
"""Record a new estimate with its reason; refused when it does not exceed the time already spent."""
|
|
467
|
+
now = now or time.time()
|
|
468
|
+
if not reason.strip():
|
|
469
|
+
raise ValueError("a new estimate needs --why")
|
|
470
|
+
if jid.startswith("O-"):
|
|
471
|
+
o = Orders(cfg).get(jid)
|
|
472
|
+
if o["state"] not in ("open", "held"):
|
|
473
|
+
raise ValueError(f"{jid} is {o['state']}; only a live order takes an estimate")
|
|
474
|
+
spent = (now - o["opened"]) / 60
|
|
475
|
+
_need_more(jid, minutes, spent)
|
|
476
|
+
Orders(cfg).estimate(jid, minutes, "hub", reason)
|
|
477
|
+
return (
|
|
478
|
+
f"{jid} estimate {minutes}m recorded ({len(o['estimates']) + 1} on record)"
|
|
479
|
+
)
|
|
480
|
+
sd = store.state(cfg)
|
|
481
|
+
with store.locked(sd / "work.lock"):
|
|
482
|
+
items = work_load(sd)
|
|
483
|
+
hit = next(
|
|
484
|
+
(i for i in items if i["id"] == jid and i.get("kind") == "routed"), None
|
|
485
|
+
)
|
|
486
|
+
if hit is None:
|
|
487
|
+
raise KeyError(f"no live routed item {jid}")
|
|
488
|
+
_need_more(jid, minutes, (now - routed.started_at(cfg, hit)) / 60)
|
|
489
|
+
hit["estimates"] = [
|
|
490
|
+
*hit.get("estimates", []),
|
|
491
|
+
{"t": now, "min": minutes, "source": "hub", "why": store.safe(reason, 200)},
|
|
492
|
+
]
|
|
493
|
+
hit.update(estimate_min=minutes, estimate_defaulted=False, addressed=[])
|
|
494
|
+
work_save(sd, items)
|
|
495
|
+
return f"{jid} estimate {minutes}m recorded ({len(hit['estimates'])} on record)"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _need_more(jid: str, minutes: int, spent: float) -> None:
|
|
499
|
+
if minutes <= spent:
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"{jid}: a new estimate must exceed the {int(spent)}m already spent; chase the holder or re-brief instead"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---- end of day: estimate vs actual, for the hub's own learning ----
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def reflection_lines(cfg: dict, day: str) -> list[str]:
|
|
509
|
+
"""Estimate vs actual per kind for items finished on `day`, and how often they were re-estimated."""
|
|
510
|
+
rows = store.read_rows(store.state(cfg) / "archive" / f"archive_{day}.jsonl")[0]
|
|
511
|
+
per: dict[str, list[tuple[float, float, int]]] = defaultdict(list)
|
|
512
|
+
for r in rows:
|
|
513
|
+
ests = r.get("estimates") or []
|
|
514
|
+
if not ests:
|
|
515
|
+
continue
|
|
516
|
+
start = (
|
|
517
|
+
r.get("routed_at") or r.get("added")
|
|
518
|
+
if r.get("type") == "work"
|
|
519
|
+
else r.get("opened")
|
|
520
|
+
)
|
|
521
|
+
key = step_of(r) if r.get("type") == "work" else "order:" + str(r.get("kind"))
|
|
522
|
+
per[key].append((float(ests[0]["min"]), (r["ts"] - start) / 60, len(ests) - 1))
|
|
523
|
+
if not per:
|
|
524
|
+
return [" estimate vs actual: nothing finished today carried an estimate"]
|
|
525
|
+
out = [" estimate vs actual (hub's own; first estimate vs minutes taken):"]
|
|
526
|
+
for k, v in sorted(per.items()):
|
|
527
|
+
out.append(
|
|
528
|
+
f" {k}: {len(v)} done, estimated {statistics.mean(x[0] for x in v):.0f}m, took {statistics.mean(x[1] for x in v):.0f}m, "
|
|
529
|
+
f"over estimate {sum(x[1] > x[0] for x in v)}, re-estimated {sum(x[2] for x in v)}x"
|
|
530
|
+
)
|
|
531
|
+
return out
|