@appchy/jarvis 0.1.36 → 0.1.39
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.
- package/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10104 -37174
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38718
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- package/dist/env.json +0 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""A shift: one instance, one task, no collisions.
|
|
2
|
+
|
|
3
|
+
`move <name> in-progress` requires already knowing the name, and nothing computed
|
|
4
|
+
it — so a scheduled instance had no entry point, and the first thing it would have
|
|
5
|
+
had to do is read the whole board and choose, which is a judgement call made before
|
|
6
|
+
any of the harness's guard rails are loaded. `next` is that entry point, and every
|
|
7
|
+
input it uses already existed: priority, `depends_on`, the bucket, the version's
|
|
8
|
+
derived status, and the task's `tier:` against `autonomy.ceiling`.
|
|
9
|
+
|
|
10
|
+
**Coordination is a file, not a service.** A `.claim` beside `task.md` holds the
|
|
11
|
+
instance id and an expiry. A second `next` never returns a claimed task; a claim
|
|
12
|
+
whose `code:` regions are already held live by another instance is refused and the
|
|
13
|
+
selector moves on, which reuses the region overlap `jarvis work code` has computed all
|
|
14
|
+
along. Expired claims are reaped on read, so an instance that crashes at 3am frees
|
|
15
|
+
its work without anyone being paged.
|
|
16
|
+
|
|
17
|
+
`status` is the same data read from the other end — what shipped, what is waiting
|
|
18
|
+
on you, what is at risk. Derived, never declared.
|
|
19
|
+
"""
|
|
20
|
+
import json
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import date, datetime, timedelta, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
27
|
+
from .frontmatter import rewrite_file
|
|
28
|
+
from .model import locate, record_session, scan
|
|
29
|
+
from .generate import _sync
|
|
30
|
+
from . import autonomy, events, peers
|
|
31
|
+
# The ceiling is read through the MODULE, never bound in with `from … import`.
|
|
32
|
+
# A `from .autonomy import CEILING` captures the value at import time, so
|
|
33
|
+
# `config.apply` setting it afterwards would leave this file quietly running on
|
|
34
|
+
# the shipped default — one fact with two owners, which is the drift the whole
|
|
35
|
+
# harness is built to avoid.
|
|
36
|
+
from .autonomy import _open_questions, tier_of
|
|
37
|
+
|
|
38
|
+
CLAIM = ".claim"
|
|
39
|
+
#: How long a claim is good for. Long enough that a real task finishes inside it,
|
|
40
|
+
#: short enough that a crashed instance's work is takeable the same day. Config
|
|
41
|
+
#: moves it (`autonomy.lease_minutes`).
|
|
42
|
+
LEASE_MINUTES = 240
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now():
|
|
46
|
+
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_claim(folder: Path):
|
|
50
|
+
"""The live claim on a task folder, or None. An expired or unreadable claim is
|
|
51
|
+
treated as absent — a lease nobody can parse must not be able to strand work
|
|
52
|
+
forever, which is the failure mode of every lock that fails closed."""
|
|
53
|
+
p = folder / CLAIM
|
|
54
|
+
if not p.is_file():
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(p.read_text())
|
|
58
|
+
expires = datetime.fromisoformat(str(data["expires"]))
|
|
59
|
+
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
|
60
|
+
return None
|
|
61
|
+
if expires <= _now():
|
|
62
|
+
return None
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write_claim(folder: Path, instance: str) -> dict:
|
|
67
|
+
data = {
|
|
68
|
+
"instance": instance,
|
|
69
|
+
# WHICH BOX, recorded because an id alone cannot say whether the session
|
|
70
|
+
# holding this is one you can talk to or one on another machine entirely.
|
|
71
|
+
# A claim never enters git, so this is the only place the pairing exists.
|
|
72
|
+
"machine": peers.here(),
|
|
73
|
+
"taken": _now().isoformat(),
|
|
74
|
+
"expires": (_now() + timedelta(minutes=LEASE_MINUTES)).isoformat(),
|
|
75
|
+
}
|
|
76
|
+
(folder / CLAIM).write_text(json.dumps(data, indent=2) + "\n")
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _live_claims(s: dict) -> list:
|
|
81
|
+
"""Every unexpired claim across the tree, as (claim, task) — the input to both
|
|
82
|
+
the same-task check and the region-conflict check."""
|
|
83
|
+
out = []
|
|
84
|
+
for v in s["versions"]:
|
|
85
|
+
for t in v.all_tasks():
|
|
86
|
+
c = read_claim(t.folder)
|
|
87
|
+
if c:
|
|
88
|
+
out.append((c, t))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _blockers(task, s: dict) -> str:
|
|
93
|
+
"""Why an unattended run may NOT take this task, or "" if it may. One string,
|
|
94
|
+
because `next` prints it when nothing is eligible — a cron log that says
|
|
95
|
+
"nothing to do" and not why is a log nobody can debug."""
|
|
96
|
+
done = {t.name for v in s["versions"] for t in v.all_tasks()
|
|
97
|
+
if t.status == "complete"}
|
|
98
|
+
missing = [d for d in task.depends_on if d not in done]
|
|
99
|
+
if missing:
|
|
100
|
+
return f"depends on incomplete: {', '.join(missing)}"
|
|
101
|
+
tier = tier_of(task)
|
|
102
|
+
if tier > autonomy.CEILING:
|
|
103
|
+
return (f"tier {tier} > autonomy.ceiling {autonomy.CEILING} — this one is yours to "
|
|
104
|
+
f"decide, not the shift's")
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _region_conflict(task, claims: list) -> str:
|
|
109
|
+
if not task.code:
|
|
110
|
+
return ""
|
|
111
|
+
for c, other in claims:
|
|
112
|
+
if other.name == task.name:
|
|
113
|
+
continue
|
|
114
|
+
overlap = sorted(set(task.code) & set(other.code))
|
|
115
|
+
if overlap:
|
|
116
|
+
# Naming the holder is only half an answer — the half that leaves you
|
|
117
|
+
# waiting for a lease. The other half is whether that session is one
|
|
118
|
+
# you can actually talk to about it.
|
|
119
|
+
return (f"code region(s) {', '.join(overlap)} held by "
|
|
120
|
+
f"'{other.name}' · {peers.of_claim(c)}")
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_next(args) -> int:
|
|
125
|
+
"""Hand this instance exactly one task, and claim it.
|
|
126
|
+
|
|
127
|
+
`--peek` selects without claiming or moving — for a human asking what the shift
|
|
128
|
+
would take next, which must not have the side effect of taking it."""
|
|
129
|
+
root = find_work_root()
|
|
130
|
+
instance = (args.get("instance") or events.instance_id() or "local").strip()
|
|
131
|
+
peek = bool(args.get("peek"))
|
|
132
|
+
s = scan(root)
|
|
133
|
+
claims = _live_claims(s)
|
|
134
|
+
|
|
135
|
+
# An instance that already holds work resumes it rather than taking more. One
|
|
136
|
+
# instance, one task — a shift juggling three tasks is three half-finished
|
|
137
|
+
# tasks, and unattended nobody notices until morning.
|
|
138
|
+
for c, t in claims:
|
|
139
|
+
if c["instance"] == instance:
|
|
140
|
+
print(f"RESUME {t.name} — {t.title} [claimed {c['taken']}]")
|
|
141
|
+
_read_order(t, root)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
live = [v for v in s["versions"] if not v.released]
|
|
145
|
+
candidates, skipped = [], []
|
|
146
|
+
for v in sorted(live, key=lambda x: (x.order, x.name)):
|
|
147
|
+
for t in v.bucket("queue"):
|
|
148
|
+
if read_claim(t.folder):
|
|
149
|
+
skipped.append((t, "claimed by another instance"))
|
|
150
|
+
continue
|
|
151
|
+
why = _blockers(t, s) or _region_conflict(t, claims)
|
|
152
|
+
if why:
|
|
153
|
+
skipped.append((t, why))
|
|
154
|
+
continue
|
|
155
|
+
candidates.append((t.priority_rank(), v.order, t.name, t))
|
|
156
|
+
|
|
157
|
+
if not candidates:
|
|
158
|
+
blocked = [t for v in live for t in v.bucket(BLOCKED)]
|
|
159
|
+
print("nothing to take.")
|
|
160
|
+
if blocked:
|
|
161
|
+
print(f" {len(blocked)} task(s) waiting on a human — `jarvis work needs`")
|
|
162
|
+
for t, why in sorted(skipped, key=lambda r: r[0].name)[:10]:
|
|
163
|
+
print(f" skipped {t.name}: {why}")
|
|
164
|
+
if not blocked and not skipped:
|
|
165
|
+
print(" the queue is empty — nothing is cut and ready.")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
candidates.sort(key=lambda r: (r[0], r[1], r[2]))
|
|
169
|
+
task = candidates[0][3]
|
|
170
|
+
|
|
171
|
+
if peek:
|
|
172
|
+
print(f"NEXT (peek) {task.name} — {task.title} "
|
|
173
|
+
f"[{task.priority} · tier {tier_of(task)}]")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
container = task.folder.parent.parent
|
|
177
|
+
dest = container / "in-progress" / task.name
|
|
178
|
+
if dest.exists():
|
|
179
|
+
die(f"{rel(dest, root)} already exists")
|
|
180
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
shutil.move(str(task.folder), str(dest))
|
|
182
|
+
md = dest / "task.md"
|
|
183
|
+
rewrite_file(
|
|
184
|
+
md,
|
|
185
|
+
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
186
|
+
)
|
|
187
|
+
claim = _write_claim(dest, instance)
|
|
188
|
+
record_session(dest)
|
|
189
|
+
events.append(root, "moved", task.name, **{"from": "queue", "to": "in-progress"})
|
|
190
|
+
events.append(root, "claimed", task.name, expires=claim["expires"])
|
|
191
|
+
|
|
192
|
+
print(f"TAKE {task.name} — {task.title} "
|
|
193
|
+
f"[{task.priority} · tier {tier_of(task)} · lease to {claim['expires']}]")
|
|
194
|
+
task = locate(root, task.name)
|
|
195
|
+
_read_order(task, root)
|
|
196
|
+
_sync(root)
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _read_order(task, root) -> None:
|
|
201
|
+
"""The cold-start spine, in the order `references/operations.md` prescribes.
|
|
202
|
+
Printed rather than assumed: an unattended session has no prior conversation,
|
|
203
|
+
so the one thing it must not have to infer is what to read."""
|
|
204
|
+
print("\n read, in this order:")
|
|
205
|
+
print(f" 1. {rel(task.folder / 'task.md', root)} the brief")
|
|
206
|
+
for f, what in (("handoff.md", "where things stand"),
|
|
207
|
+
("plan.md", "design + as-found + build"),
|
|
208
|
+
("research.md", "raw findings")):
|
|
209
|
+
if (task.folder / f).is_file():
|
|
210
|
+
print(f" {rel(task.folder / f, root)} {what}")
|
|
211
|
+
print(f" 2. jarvis work rules --task {task.name} what constrains it")
|
|
212
|
+
if task.epic:
|
|
213
|
+
print(f" 3. the parent epic.md §Plan the design every sibling builds against")
|
|
214
|
+
print(f" 4. jarvis work verify before you claim it is done\n")
|
|
215
|
+
print(f" tier {tier_of(task)} · ceiling {autonomy.CEILING} — "
|
|
216
|
+
+ ("decide inside the plan; park product/UX calls with `jarvis work ask`."
|
|
217
|
+
if tier_of(task) <= autonomy.CEILING else
|
|
218
|
+
"ABOVE CEILING: do not decide alone."))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_drop(args) -> int:
|
|
222
|
+
"""Give a task back — the honest end to a shift that cannot finish it."""
|
|
223
|
+
root = find_work_root()
|
|
224
|
+
task = locate(root, args["name"])
|
|
225
|
+
if not task:
|
|
226
|
+
die(f"no task named '{args['name']}' found")
|
|
227
|
+
p = task.folder / CLAIM
|
|
228
|
+
if p.is_file():
|
|
229
|
+
p.unlink()
|
|
230
|
+
events.append(root, "released-claim", task.name,
|
|
231
|
+
why=(args.get("why") or "").strip() or None)
|
|
232
|
+
print(f"released claim on '{task.name}'")
|
|
233
|
+
else:
|
|
234
|
+
print(f"'{task.name}' has no claim")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ago(ts: str) -> str:
|
|
239
|
+
"""How long ago a recorded moment was, in the coarsest unit that still says
|
|
240
|
+
something. "6h ago" answers *is anyone on this* far better than a timestamp the
|
|
241
|
+
reader has to subtract from now, and the difference between 6 and 7 hours never
|
|
242
|
+
changes what they do about it."""
|
|
243
|
+
try:
|
|
244
|
+
when = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
245
|
+
except (TypeError, ValueError, AttributeError):
|
|
246
|
+
return "at an unrecorded time"
|
|
247
|
+
if when.tzinfo is None:
|
|
248
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
249
|
+
mins = int((datetime.now(timezone.utc) - when).total_seconds() // 60)
|
|
250
|
+
if mins < 1:
|
|
251
|
+
return "just now"
|
|
252
|
+
if mins < 60:
|
|
253
|
+
return f"{mins}m ago"
|
|
254
|
+
if mins < 60 * 48:
|
|
255
|
+
return f"{mins // 60}h ago"
|
|
256
|
+
return f"{mins // (60 * 24)}d ago"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_status(args) -> int:
|
|
260
|
+
"""The founder's morning read, in the order the questions actually get asked.
|
|
261
|
+
|
|
262
|
+
Every line is derived — from the tree for what is true now, from the log for
|
|
263
|
+
what happened. Nothing here is a field somebody wrote saying how it is going."""
|
|
264
|
+
root = find_work_root()
|
|
265
|
+
from .report import _say_staleness
|
|
266
|
+
|
|
267
|
+
_say_staleness(root)
|
|
268
|
+
s = scan(root)
|
|
269
|
+
live = [v for v in s["versions"] if not v.released]
|
|
270
|
+
|
|
271
|
+
waiting = [(t, q) for v in s["versions"] for t in v.all_tasks()
|
|
272
|
+
for q in _open_questions(t)]
|
|
273
|
+
print(f"WAITING ON YOU ({len(waiting)})")
|
|
274
|
+
for t, q in waiting[:8]:
|
|
275
|
+
parts = q.split(" ")
|
|
276
|
+
print(f" {t.name}: {' '.join(parts[2:]) if len(parts) > 2 else q}")
|
|
277
|
+
if not waiting:
|
|
278
|
+
print(" nothing — the shift is not blocked on you.")
|
|
279
|
+
|
|
280
|
+
# One read of the record, shared by every section below. It is a `git log`
|
|
281
|
+
# under the git backend, so asking three times is three subprocesses for one
|
|
282
|
+
# answer that cannot change between them.
|
|
283
|
+
log = events.read(root)
|
|
284
|
+
recent = [e for e in log if e["event"] == "completed"][-5:]
|
|
285
|
+
print(f"\nSHIPPED (last {len(recent)})")
|
|
286
|
+
for e in recent:
|
|
287
|
+
print(f" {e['ts'][:10]} {e['name']}"
|
|
288
|
+
+ (f" — {e['delivered']}" if e.get("delivered") else ""))
|
|
289
|
+
if not recent:
|
|
290
|
+
print(" nothing recorded yet.")
|
|
291
|
+
|
|
292
|
+
# Who last touched each item, out of the commits. A lease answers this only for
|
|
293
|
+
# the unattended loop — an interactive session writes none at all, so reading
|
|
294
|
+
# claims alone printed `unclaimed` against every item a person was working and
|
|
295
|
+
# told the one screen the shift is read from nothing whatsoever.
|
|
296
|
+
touched = {}
|
|
297
|
+
for e in log:
|
|
298
|
+
if e.get("by"):
|
|
299
|
+
touched[e["name"]] = e
|
|
300
|
+
|
|
301
|
+
in_flight = [t for v in live for t in v.bucket("in-progress")]
|
|
302
|
+
print(f"\nIN FLIGHT ({len(in_flight)})")
|
|
303
|
+
for t in in_flight:
|
|
304
|
+
c = read_claim(t.folder)
|
|
305
|
+
last = touched.get(t.name)
|
|
306
|
+
if c:
|
|
307
|
+
who = f" · {c['instance'][:8]} until {c['expires'][11:16]}Z"
|
|
308
|
+
elif last:
|
|
309
|
+
who = f" · last touched {_ago(last['ts'])}"
|
|
310
|
+
else:
|
|
311
|
+
who = " · never touched"
|
|
312
|
+
print(f" {t.name} — {t.title} [tier {tier_of(t)}{who}]")
|
|
313
|
+
# Only for work that is somebody ELSE's: on your own tasks this would be a
|
|
314
|
+
# line about yourself on every status, which is how a useful line becomes
|
|
315
|
+
# one people stop reading.
|
|
316
|
+
if c and c.get("instance") != peers.me():
|
|
317
|
+
print(f" {peers.of_claim(c)}")
|
|
318
|
+
elif not c and last and last["by"] != peers.me():
|
|
319
|
+
print(f" {peers.describe(last['by'], last.get('machine', ''))}")
|
|
320
|
+
if not in_flight:
|
|
321
|
+
print(" nothing is being worked on.")
|
|
322
|
+
|
|
323
|
+
# At risk: claimed-but-expired (an instance died), and gates that keep refusing.
|
|
324
|
+
stale = [t for t in in_flight if not read_claim(t.folder) and (t.folder / CLAIM).is_file()]
|
|
325
|
+
# A task refused repeatedly is a task not converging — UNLESS it is parked with
|
|
326
|
+
# an open question, which is the designed outcome rather than a failure. Counting
|
|
327
|
+
# those would put the harness's own correct behaviour on the risk list, and a
|
|
328
|
+
# risk list with routine entries on it is one that stops being read.
|
|
329
|
+
waiting = {t.name for v in live for t in v.bucket(BLOCKED)}
|
|
330
|
+
refused = {}
|
|
331
|
+
for e in log:
|
|
332
|
+
if e["event"] == "gate-refused" and e["name"] not in waiting:
|
|
333
|
+
refused[e["name"]] = refused.get(e["name"], 0) + 1
|
|
334
|
+
repeat = {n: c for n, c in refused.items() if c >= 2}
|
|
335
|
+
print(f"\nAT RISK ({len(stale) + len(repeat)})")
|
|
336
|
+
for t in stale:
|
|
337
|
+
print(f" {t.name}: lease expired — the instance working it is gone")
|
|
338
|
+
for n, c in sorted(repeat.items()):
|
|
339
|
+
print(f" {n}: completion refused {c}× — it is not converging")
|
|
340
|
+
if not stale and not repeat:
|
|
341
|
+
print(" nothing.")
|
|
342
|
+
|
|
343
|
+
print("\nBOARD")
|
|
344
|
+
for v in sorted(s["versions"], key=lambda x: (x.order, x.name)):
|
|
345
|
+
counts = " · ".join(f"{b} {len(v.bucket(b))}" for b in BUCKETS
|
|
346
|
+
if v.bucket(b))
|
|
347
|
+
print(f" {v.name} [{v.status()}] {counts or 'empty'}")
|
|
348
|
+
return 0
|