@appchy/jarvis 0.1.129 → 0.1.131
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/dist/bin.js +5 -2
- package/dist/bin.js.map +1 -1
- package/harness/harness/autonomy.py +3 -6
- package/harness/harness/config.py +0 -8
- package/harness/harness/events.py +26 -27
- package/harness/harness/gate.py +2 -2
- package/harness/harness/generate.py +4 -0
- package/harness/harness/git.py +31 -20
- package/harness/harness/holders.cases.json +173 -0
- package/harness/harness/holders.py +72 -0
- package/harness/harness/lint.py +47 -0
- package/harness/harness/migrate.py +31 -0
- package/harness/harness/model.py +26 -0
- package/harness/harness/peers.cases.json +36 -25
- package/harness/harness/peers.py +26 -18
- package/harness/harness/report.py +6 -0
- package/harness/harness/shift.py +94 -132
- package/harness/harness/task.py +5 -11
- package/harness/harness/tree.py +17 -0
- package/harness/harness/version.py +4 -6
- package/harness/presets/appchy/PRESET.md +13 -15
- package/harness/presets/appchy/references/operations.md +1 -1
- package/harness/schema/work.config.schema.json +0 -6
- package/harness/test_work.py +228 -107
- package/harness/work.py +3 -8
- package/package.json +3 -3
package/harness/harness/shift.py
CHANGED
|
@@ -7,28 +7,28 @@ any of the harness's guard rails are loaded. `next` is that entry point, and eve
|
|
|
7
7
|
input it uses already existed: priority, `depends_on`, the bucket, the version's
|
|
8
8
|
derived status, and the task's `tier:` against `autonomy.ceiling`.
|
|
9
9
|
|
|
10
|
-
**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
**Who is on something is read from the board's own history, and from nothing else.**
|
|
11
|
+
Taking an item is the commit that moved it into progress, or that recorded a run on
|
|
12
|
+
one already there, so a second `next` sees every session's work — a person's at a
|
|
13
|
+
terminal included, which a lease written only by this loop never could. A task whose
|
|
14
|
+
`code:` regions overlap an in-progress item somebody may still be working is skipped,
|
|
15
|
+
and the selector moves on. Nothing expires and nothing is reaped: an area is free
|
|
16
|
+
once everyone holding it has ended.
|
|
16
17
|
|
|
17
18
|
`status` is the same data read from the other end — what shipped, what is waiting
|
|
18
19
|
on you, what is at risk. Derived, never declared.
|
|
19
20
|
"""
|
|
20
|
-
import
|
|
21
|
+
import os
|
|
21
22
|
import shutil
|
|
22
23
|
import sys
|
|
23
|
-
from datetime import date, datetime,
|
|
24
|
-
from pathlib import Path
|
|
24
|
+
from datetime import date, datetime, timezone
|
|
25
25
|
|
|
26
26
|
from .tree import BLOCKED, BUCKETS, die, find_work_root, rel
|
|
27
27
|
from .frontmatter import rewrite_file
|
|
28
|
-
from .model import locate,
|
|
28
|
+
from .model import locate, record_session, scan
|
|
29
29
|
from .generate import _sync
|
|
30
30
|
from .epic import plans_held
|
|
31
|
-
from . import autonomy, events, links, peers
|
|
31
|
+
from . import autonomy, events, holders, links, peers
|
|
32
32
|
# The ceiling is read through the MODULE, never bound in with `from … import`.
|
|
33
33
|
# A `from .autonomy import CEILING` captures the value at import time, so
|
|
34
34
|
# `config.apply` setting it afterwards would leave this file quietly running on
|
|
@@ -36,64 +36,39 @@ from . import autonomy, events, links, peers
|
|
|
36
36
|
# harness is built to avoid.
|
|
37
37
|
from .autonomy import _open_questions, tier_of
|
|
38
38
|
|
|
39
|
-
CLAIM = ".claim"
|
|
40
|
-
#: How long a claim is good for. Long enough that a real task finishes inside it,
|
|
41
|
-
#: short enough that a crashed instance's work is takeable the same day. Config
|
|
42
|
-
#: moves it (`autonomy.lease_minutes`).
|
|
43
|
-
LEASE_MINUTES = 240
|
|
44
39
|
|
|
40
|
+
def _takes(root) -> dict:
|
|
41
|
+
"""Everyone on every item, out of the board's own history — one read for the
|
|
42
|
+
whole tree, shared by every question this module asks of it."""
|
|
43
|
+
return holders.to_holders_by_item(events.read(root, eventless=True))
|
|
45
44
|
|
|
46
|
-
def _now():
|
|
47
|
-
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
48
45
|
|
|
46
|
+
def _still_there(holder: dict) -> bool:
|
|
47
|
+
"""Whether a holder may still be working, for the one decision this gates.
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _write_claim(folder: Path, instance: str) -> dict:
|
|
74
|
-
data = {
|
|
75
|
-
"instance": instance,
|
|
76
|
-
# WHICH BOX, recorded because an id alone cannot say whether the session
|
|
77
|
-
# holding this is one you can talk to or one on another machine entirely.
|
|
78
|
-
# A claim never enters git, so this is the only place the pairing exists.
|
|
79
|
-
"machine": peers.here(),
|
|
80
|
-
"taken": _now().isoformat(),
|
|
81
|
-
"expires": (_now() + timedelta(minutes=LEASE_MINUTES)).isoformat(),
|
|
82
|
-
}
|
|
83
|
-
(folder / CLAIM).write_text(json.dumps(data, indent=2) + "\n")
|
|
84
|
-
return data
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _live_claims(s: dict) -> list:
|
|
88
|
-
"""Every unexpired claim across the tree, as (claim, task) — the input to both
|
|
89
|
-
the same-task check and the region-conflict check."""
|
|
90
|
-
out = []
|
|
91
|
-
for v in s["versions"]:
|
|
92
|
-
for t in v.all_tasks():
|
|
93
|
-
c = read_claim(t.folder)
|
|
94
|
-
if c:
|
|
95
|
-
out.append((c, t))
|
|
96
|
-
return out
|
|
49
|
+
Only `ended` says no. A session this machine cannot see — on another machine, on
|
|
50
|
+
a client that publishes nothing, or a shift that names itself — counts as still
|
|
51
|
+
there, because what this answer decides is the unattended loop HOLDING BACK: a
|
|
52
|
+
wrong yes costs a task not taken tonight, and a wrong no costs two runs on one
|
|
53
|
+
area. It never refuses a person and never lets anything go ahead that would not
|
|
54
|
+
have gone ahead anyway."""
|
|
55
|
+
return peers.reach(holder["session"], holder["machine"])["live"] != "ended"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def somebody_else(root, name: str) -> str:
|
|
59
|
+
"""A note for whoever is about to move `name` when somebody other than them may
|
|
60
|
+
still be on it, or "" when nobody is.
|
|
61
|
+
|
|
62
|
+
It warns and never refuses: `next` is the door that declines held work, and a
|
|
63
|
+
person who has decided to take something over is owed who to talk to before the
|
|
64
|
+
two of them collide, not an argument about it."""
|
|
65
|
+
mine = peers.me()
|
|
66
|
+
others = [h for h in holders.to_holders(events.read(root, name=name, eventless=True))
|
|
67
|
+
if h["session"] != mine and _still_there(h)]
|
|
68
|
+
if not others:
|
|
69
|
+
return ""
|
|
70
|
+
said = "; ".join(peers.describe(h["session"], h["machine"]) for h in others)
|
|
71
|
+
return f"note: '{name}' may still be held by another session — {said}"
|
|
97
72
|
|
|
98
73
|
|
|
99
74
|
def _blockers(task, s: dict) -> str:
|
|
@@ -112,50 +87,59 @@ def _blockers(task, s: dict) -> str:
|
|
|
112
87
|
return ""
|
|
113
88
|
|
|
114
89
|
|
|
115
|
-
def _region_conflict(task,
|
|
90
|
+
def _region_conflict(task, held: list) -> str:
|
|
91
|
+
"""Why `task` overlaps work somebody may still be doing, or "".
|
|
92
|
+
|
|
93
|
+
`held` is every (holder, in-progress item) pair whose holder is not this instance
|
|
94
|
+
and has not ended. Overlap is exact and says nothing finer: where a repo's work
|
|
95
|
+
sits in one region, everything waits while anyone is working, which is the safe
|
|
96
|
+
direction for an unattended run to be wrong in."""
|
|
116
97
|
if not task.code:
|
|
117
98
|
return ""
|
|
118
|
-
for
|
|
99
|
+
for holder, other in held:
|
|
119
100
|
if other.name == task.name:
|
|
120
101
|
continue
|
|
121
102
|
overlap = sorted(set(task.code) & set(other.code))
|
|
122
103
|
if overlap:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# you can actually talk to about it.
|
|
126
|
-
return (f"code region(s) {', '.join(overlap)} held by "
|
|
127
|
-
f"'{other.name}' · {peers.of_claim(c)}")
|
|
104
|
+
return (f"code region(s) {', '.join(overlap)} held by '{other.name}' · "
|
|
105
|
+
f"{peers.describe(holder['session'], holder['machine'])}")
|
|
128
106
|
return ""
|
|
129
107
|
|
|
130
108
|
|
|
131
109
|
def cmd_next(args) -> int:
|
|
132
|
-
"""Hand this instance exactly one task
|
|
110
|
+
"""Hand this instance exactly one task.
|
|
133
111
|
|
|
134
|
-
`--peek` selects without
|
|
112
|
+
`--peek` selects without taking or moving — for a human asking what the shift
|
|
135
113
|
would take next, which must not have the side effect of taking it."""
|
|
136
114
|
root = find_work_root()
|
|
137
|
-
instance = (args.get("instance") or events.instance_id() or "
|
|
115
|
+
instance = (args.get("instance") or events.instance_id() or "").strip()
|
|
116
|
+
if args.get("instance"):
|
|
117
|
+
# A take is recorded under whoever this run is known as, so a name given here
|
|
118
|
+
# has to be that name — or this run would read its own take as somebody
|
|
119
|
+
# else's and hold back from its own work.
|
|
120
|
+
os.environ["WORK_INSTANCE"] = instance
|
|
138
121
|
peek = bool(args.get("peek"))
|
|
139
122
|
s = scan(root)
|
|
140
|
-
|
|
123
|
+
live = [v for v in s["versions"] if not v.released]
|
|
124
|
+
in_flight = [t for v in live for t in v.bucket("in-progress")]
|
|
125
|
+
takes = _takes(root)
|
|
141
126
|
|
|
142
127
|
# An instance that already holds work resumes it rather than taking more. One
|
|
143
128
|
# instance, one task — a shift juggling three tasks is three half-finished
|
|
144
129
|
# tasks, and unattended nobody notices until morning.
|
|
145
|
-
for
|
|
146
|
-
if
|
|
147
|
-
|
|
130
|
+
for t in in_flight:
|
|
131
|
+
mine = [h for h in takes.get(t.name, []) if instance and h["session"] == instance]
|
|
132
|
+
if mine:
|
|
133
|
+
print(f"RESUME {t.name} — {t.title} [taken {mine[0]['taken']}]")
|
|
148
134
|
_read_order(t, root)
|
|
149
135
|
return 0
|
|
150
136
|
|
|
151
|
-
|
|
137
|
+
held = [(h, t) for t in in_flight for h in takes.get(t.name, [])
|
|
138
|
+
if h["session"] != instance and _still_there(h)]
|
|
152
139
|
candidates, skipped = [], []
|
|
153
140
|
for v in sorted(live, key=lambda x: (x.order, x.name)):
|
|
154
141
|
for t in v.bucket("queue"):
|
|
155
|
-
|
|
156
|
-
skipped.append((t, "claimed by another instance"))
|
|
157
|
-
continue
|
|
158
|
-
why = _blockers(t, s) or _region_conflict(t, claims)
|
|
142
|
+
why = _blockers(t, s) or _region_conflict(t, held)
|
|
159
143
|
if why:
|
|
160
144
|
skipped.append((t, why))
|
|
161
145
|
continue
|
|
@@ -193,13 +177,13 @@ def cmd_next(args) -> int:
|
|
|
193
177
|
md,
|
|
194
178
|
lambda d: d.update({"updated": date.today().isoformat()}),
|
|
195
179
|
)
|
|
196
|
-
claim = _write_claim(dest, instance)
|
|
197
180
|
record_session(dest)
|
|
181
|
+
# The move IS the take: its commit names the run and the machine, and that record
|
|
182
|
+
# is the one every reader derives who is on this from.
|
|
198
183
|
events.append(root, "moved", task.name, **{"from": "queue", "to": "in-progress"})
|
|
199
|
-
events.append(root, "claimed", task.name, expires=claim["expires"])
|
|
200
184
|
|
|
201
185
|
print(f"TAKE {task.name} — {task.title} "
|
|
202
|
-
f"[{task.priority} · tier {tier_of(task)}
|
|
186
|
+
f"[{task.priority} · tier {tier_of(task)}]")
|
|
203
187
|
task = locate(root, task.name)
|
|
204
188
|
_read_order(task, root)
|
|
205
189
|
_sync(root)
|
|
@@ -227,23 +211,6 @@ def _read_order(task, root) -> None:
|
|
|
227
211
|
"ABOVE CEILING: do not decide alone."))
|
|
228
212
|
|
|
229
213
|
|
|
230
|
-
def cmd_drop(args) -> int:
|
|
231
|
-
"""Give a task back — the honest end to a shift that cannot finish it."""
|
|
232
|
-
root = find_work_root()
|
|
233
|
-
task = locate(root, args["name"])
|
|
234
|
-
if not task:
|
|
235
|
-
die(missing(root, args["name"]))
|
|
236
|
-
p = task.folder / CLAIM
|
|
237
|
-
if p.is_file():
|
|
238
|
-
p.unlink()
|
|
239
|
-
events.append(root, "released-claim", task.name,
|
|
240
|
-
why=(args.get("why") or "").strip() or None)
|
|
241
|
-
print(f"released claim on '{task.name}'")
|
|
242
|
-
else:
|
|
243
|
-
print(f"'{task.name}' has no claim")
|
|
244
|
-
return 0
|
|
245
|
-
|
|
246
|
-
|
|
247
214
|
def _ago(ts: str) -> str:
|
|
248
215
|
"""How long ago a recorded moment was, in the coarsest unit that still says
|
|
249
216
|
something. "6h ago" answers *is anyone on this* far better than a timestamp the
|
|
@@ -303,7 +270,7 @@ def cmd_status(args) -> int:
|
|
|
303
270
|
# One read of the record, shared by every section below. It is a `git log`
|
|
304
271
|
# under the git backend, so asking three times is three subprocesses for one
|
|
305
272
|
# answer that cannot change between them.
|
|
306
|
-
log = events.read(root)
|
|
273
|
+
log = events.read(root, eventless=True)
|
|
307
274
|
recent = [e for e in log if e["event"] == "completed"][-5:]
|
|
308
275
|
print(f"\nSHIPPED (last {len(recent)})")
|
|
309
276
|
for e in recent:
|
|
@@ -312,39 +279,36 @@ def cmd_status(args) -> int:
|
|
|
312
279
|
if not recent:
|
|
313
280
|
print(" nothing recorded yet.")
|
|
314
281
|
|
|
315
|
-
# Who
|
|
316
|
-
#
|
|
317
|
-
|
|
318
|
-
# told the one screen the shift is read from nothing whatsoever.
|
|
319
|
-
touched = {}
|
|
320
|
-
for e in log:
|
|
321
|
-
if e.get("by"):
|
|
322
|
-
touched[e["name"]] = e
|
|
282
|
+
# Who is on each item, out of the commits that took it — the derivation the board
|
|
283
|
+
# and `next` read too, so the three cannot name different holders.
|
|
284
|
+
takes = holders.to_holders_by_item(log)
|
|
323
285
|
|
|
324
286
|
in_flight = [t for v in live for t in v.bucket("in-progress")]
|
|
325
287
|
print(f"\nIN FLIGHT ({len(in_flight)})")
|
|
326
288
|
for t in in_flight:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if
|
|
330
|
-
who = f" · {c['instance'][:8]} until {c['expires'][11:16]}Z"
|
|
331
|
-
elif last:
|
|
332
|
-
who = f" · last touched {_ago(last['ts'])}"
|
|
333
|
-
else:
|
|
334
|
-
who = " · never touched"
|
|
289
|
+
found = takes.get(t.name, [])
|
|
290
|
+
latest = max(found, key=lambda h: h["taken"]) if found else None
|
|
291
|
+
who = f" · last taken {_ago(latest['taken'])}" if latest else " · never taken"
|
|
335
292
|
print(f" {t.name} — {t.title} [tier {tier_of(t)}{who}]")
|
|
336
293
|
# Only for work that is somebody ELSE's: on your own tasks this would be a
|
|
337
294
|
# line about yourself on every status, which is how a useful line becomes
|
|
338
|
-
# one people stop reading.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
295
|
+
# one people stop reading. Takes whose run has ended collapse to a count, and
|
|
296
|
+
# the newest of them is named only when nobody at all is left on the item.
|
|
297
|
+
others = [h for h in found if h["session"] != peers.me()]
|
|
298
|
+
still = [h for h in others if _still_there(h)]
|
|
299
|
+
for h in still:
|
|
300
|
+
print(f" {peers.describe(h['session'], h['machine'])}")
|
|
301
|
+
ended = [h for h in others if h not in still]
|
|
302
|
+
if ended and not still:
|
|
303
|
+
newest = max(ended, key=lambda h: h["taken"])
|
|
304
|
+
print(f" {peers.describe(newest['session'], newest['machine'])}")
|
|
305
|
+
ended.remove(newest)
|
|
306
|
+
if ended:
|
|
307
|
+
print(f" + {len(ended)} earlier take(s) whose run has ended")
|
|
343
308
|
if not in_flight:
|
|
344
309
|
print(" nothing is being worked on.")
|
|
345
310
|
|
|
346
|
-
# At risk:
|
|
347
|
-
stale = [t for t in in_flight if not read_claim(t.folder) and (t.folder / CLAIM).is_file()]
|
|
311
|
+
# At risk: gates that keep refusing.
|
|
348
312
|
# A task refused repeatedly is a task not converging — UNLESS it is parked with
|
|
349
313
|
# an open question, which is the designed outcome rather than a failure. Counting
|
|
350
314
|
# those would put the harness's own correct behaviour on the risk list, and a
|
|
@@ -355,12 +319,10 @@ def cmd_status(args) -> int:
|
|
|
355
319
|
if e["event"] == "gate-refused" and e["name"] not in waiting:
|
|
356
320
|
refused[e["name"]] = refused.get(e["name"], 0) + 1
|
|
357
321
|
repeat = {n: c for n, c in refused.items() if c >= 2}
|
|
358
|
-
print(f"\nAT RISK ({len(
|
|
359
|
-
for t in stale:
|
|
360
|
-
print(f" {t.name}: lease expired — the instance working it is gone")
|
|
322
|
+
print(f"\nAT RISK ({len(repeat)})")
|
|
361
323
|
for n, c in sorted(repeat.items()):
|
|
362
324
|
print(f" {n}: completion refused {c}× — it is not converging")
|
|
363
|
-
if not
|
|
325
|
+
if not repeat:
|
|
364
326
|
print(" nothing.")
|
|
365
327
|
|
|
366
328
|
print("\nBOARD")
|
package/harness/harness/task.py
CHANGED
|
@@ -3,7 +3,7 @@ import shutil
|
|
|
3
3
|
import sys
|
|
4
4
|
from datetime import date
|
|
5
5
|
|
|
6
|
-
from . import
|
|
6
|
+
from . import tree
|
|
7
7
|
# `TASK_TAGS_OK` is deliberately NOT imported by name: `config.apply` binds it on
|
|
8
8
|
# the `tree` module, and a `from … import` captures the value at import time, so
|
|
9
9
|
# the name here would still hold the pre-config default. Read through the module.
|
|
@@ -334,11 +334,10 @@ def cmd_move(args) -> int:
|
|
|
334
334
|
# what a person uses when they have decided to take something over — and a
|
|
335
335
|
# harness that argued with that would just get worked around. What it owes is
|
|
336
336
|
# the fact, and who to talk to before the two of you collide.
|
|
337
|
-
from .shift import
|
|
338
|
-
|
|
339
|
-
if
|
|
340
|
-
print(
|
|
341
|
-
file=sys.stderr)
|
|
337
|
+
from .shift import somebody_else
|
|
338
|
+
note = somebody_else(root, name)
|
|
339
|
+
if note:
|
|
340
|
+
print(note, file=sys.stderr)
|
|
342
341
|
|
|
343
342
|
# The gate runs BEFORE the folder moves. It used to warn after — which meant
|
|
344
343
|
# the task was already sitting in `complete/` when the warning printed, and
|
|
@@ -405,11 +404,6 @@ def cmd_move(args) -> int:
|
|
|
405
404
|
# (a blank scaffold on every pickup was noise). Working checklists live in
|
|
406
405
|
# native TodoWrite, so completion strips nothing.
|
|
407
406
|
if to == "complete":
|
|
408
|
-
# The lease is the claim on WORK IN FLIGHT; finished work holds nothing.
|
|
409
|
-
# Leaving it would make `status` report a live instance on a done task.
|
|
410
|
-
claim = dest / ".claim"
|
|
411
|
-
if claim.is_file():
|
|
412
|
-
claim.unlink()
|
|
413
407
|
events.append(root, "completed", name, delivered=delivered or None,
|
|
414
408
|
not_included=not_included or None)
|
|
415
409
|
# The forwardable line. `not_included` is asked for every time and never
|
package/harness/harness/tree.py
CHANGED
|
@@ -133,6 +133,23 @@ TASK_REGION_CAP = 3
|
|
|
133
133
|
# has not cut yet), which is why this warns rather than blocks: an epic that is
|
|
134
134
|
# planned deep and cut shallow is legitimate, and says so in its own §Plan.
|
|
135
135
|
EPIC_TASK_FLOOR = 3
|
|
136
|
+
# The other end of the epic bound, and the first bound on a CUT. Both count OPEN
|
|
137
|
+
# work — queue, in-progress, blocked — because what is hard to plan together, or to
|
|
138
|
+
# close, is what is still owed rather than what already shipped inside it.
|
|
139
|
+
#
|
|
140
|
+
# Soft in exactly the shape the floor and the region cap already are. An epic past
|
|
141
|
+
# its ceiling warns, and a written `one_goal:` answers it. A cut past its ceiling is
|
|
142
|
+
# FLAGGED as owing a reshape — never refused, never routed. Founder's call,
|
|
143
|
+
# 2026-09-12, made against the live counts (epics at 17, 10, 8, 7 open; cuts at 24,
|
|
144
|
+
# 15, 13, 9): "it should be resolved and either be in a new cut or the epic, depends
|
|
145
|
+
# on the task … to make sure we dont land a half baked cut". Where a task belongs is
|
|
146
|
+
# a planning call, so the harness says what is owed and a person decides.
|
|
147
|
+
#
|
|
148
|
+
# These are only the shipped defaults: `ceilings` in config moves them, because the
|
|
149
|
+
# harness serves repos of very different sizes. READ THROUGH THE MODULE — a
|
|
150
|
+
# `from .tree import` captures the default before config has bound the repo's own.
|
|
151
|
+
EPIC_TASK_CEILING = 8
|
|
152
|
+
CUT_TASK_CEILING = 15
|
|
136
153
|
# A feature's lifecycle, product-language not engineering-status.
|
|
137
154
|
PRODUCT_STATES = ("idea", "defined", "building", "shipped", "retired")
|
|
138
155
|
BACKLOG_START = "<!-- BACKLOG:START -->"
|
|
@@ -348,12 +348,10 @@ def _archive_task(root, name: str, args) -> int:
|
|
|
348
348
|
# Somebody may be on it. Both notes WARN rather than refuse, exactly as `move`
|
|
349
349
|
# and the take-it-out-of-a-cut door already do: a harness that argues about a
|
|
350
350
|
# call the person has made gets worked around.
|
|
351
|
-
from .shift import
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
print(f"note: '{name}' is held by another session — {peers.of_claim(held)}",
|
|
356
|
-
file=sys.stderr)
|
|
351
|
+
from .shift import somebody_else
|
|
352
|
+
note = somebody_else(root, name)
|
|
353
|
+
if note:
|
|
354
|
+
print(note, file=sys.stderr)
|
|
357
355
|
if task.status == "in-progress":
|
|
358
356
|
print(f"note: '{name}' is in progress — somebody may be working it right "
|
|
359
357
|
f"now, and this takes it out from under them.", file=sys.stderr)
|
|
@@ -555,31 +555,30 @@ Work-Session: <the run that made the change>
|
|
|
555
555
|
```
|
|
556
556
|
|
|
557
557
|
Four keys and no more: who, when and which branch are git's own, and a second copy of a fact is a
|
|
558
|
-
second thing to drift.
|
|
559
|
-
|
|
560
|
-
because nothing changed for a commit to carry.
|
|
558
|
+
second thing to drift. One thing never reaches git — a **refused completion**, because nothing changed
|
|
559
|
+
for a commit to carry.
|
|
561
560
|
|
|
562
561
|
**One consequence worth holding on to**: most commits in this repo are board writes that change no
|
|
563
562
|
code, and `stale` is defined as *graph older than HEAD*. See §Plan against the graph.
|
|
564
563
|
|
|
565
564
|
## Somebody else is on it
|
|
566
565
|
|
|
567
|
-
The harness knows who else is working —
|
|
568
|
-
|
|
569
|
-
them**:
|
|
566
|
+
The harness knows who else is working — every board commit carries `Work-Session` and
|
|
567
|
+
`Work-Machine`, and who is on an item is derived from the commits that took it. Where that matters it
|
|
568
|
+
says **whether you can reach them**:
|
|
570
569
|
|
|
571
570
|
| You see it | When |
|
|
572
571
|
|---|---|
|
|
573
572
|
| the session block, at startup | somebody else is live in this checkout — who, on what, and how to reach them. **Silence means you are alone** |
|
|
574
573
|
| `jarvis work peers` | you asked again, because that line was true when it printed and not for long |
|
|
575
|
-
| `next` skips a task | its `code:` regions
|
|
576
|
-
| `move <name> in-progress` |
|
|
574
|
+
| `next` skips a task | its `code:` regions overlap an item held by a session that has not ended, or that this machine cannot see |
|
|
575
|
+
| `move <name> in-progress` | somebody else took it and may still be on it — a **note**, not a refusal |
|
|
577
576
|
| `status` → IN FLIGHT | a task is held by a session that is not you |
|
|
578
577
|
| a board read | every holder carries whether its run is still going and what reaches it |
|
|
579
578
|
| any write | a pull brought board changes in: *the board moved under you — <what> · from <who>* |
|
|
580
579
|
|
|
581
580
|
- **`— this machine`** → that session is addressable. `ListAgents` lists it, `SendMessage` reaches it.
|
|
582
|
-
Use it when the plan has to change
|
|
581
|
+
Use it when the plan has to change.
|
|
583
582
|
- **`— another machine`** → it cannot be reached from here. The board is the only thing you share, so
|
|
584
583
|
say it on the board: `ask`, a `handoff`, or a task note.
|
|
585
584
|
- **`ENDED`** → that is abandoned work, not a peer to negotiate with. Nobody is there to ask, so the
|
|
@@ -615,10 +614,9 @@ this repo that refuses produced a hand-written forty-iteration retry loop — so
|
|
|
615
614
|
conversation is your call. A `move` that warns still moves — `next` is the door that declines held
|
|
616
615
|
work.
|
|
617
616
|
|
|
618
|
-
**
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
not written into the tree. Take an item when you are about to start it, not to reserve it.
|
|
617
|
+
**There is no hold.** Who is on an item is the commits that took it, read the same way by `status`, the
|
|
618
|
+
board and `next`. Nothing is written beside them, nothing expires, and nothing locks anybody out. Take
|
|
619
|
+
an item when you are about to start it, not to reserve it — a take is on the record for good.
|
|
622
620
|
|
|
623
621
|
## Running unattended — what you decide, and what you park
|
|
624
622
|
|
|
@@ -659,7 +657,7 @@ this section exists to prevent, and nothing downstream can detect it.
|
|
|
659
657
|
**The loop a scheduled instance runs:**
|
|
660
658
|
|
|
661
659
|
```
|
|
662
|
-
jarvis work next # take ONE task
|
|
660
|
+
jarvis work next # take ONE task and print the read order
|
|
663
661
|
# … read what it printed, build, and then, through TOOLS:
|
|
664
662
|
work_verify {id} # runs every gate; ask again until it is finished
|
|
665
663
|
work_complete {id, delivered, notIncluded, observed}
|
|
@@ -667,7 +665,7 @@ work_complete {id, delivered, notIncluded, observed}
|
|
|
667
665
|
work_update {id, question: {…}} # park it and go back to `next`
|
|
668
666
|
```
|
|
669
667
|
|
|
670
|
-
`next` is the one step with no tool:
|
|
668
|
+
`next` is the one step with no tool: taking work unattended is a scheduler's job, not a session's.
|
|
671
669
|
Everything after it is a tool call, and a session that cannot finish through them has found a gap
|
|
672
670
|
worth parking a question about.
|
|
673
671
|
|
|
@@ -231,7 +231,7 @@ what `next` adds is *choosing*, and what `ask` adds is somewhere to put a questi
|
|
|
231
231
|
so the run never stalls and never quietly decides for the founder.
|
|
232
232
|
|
|
233
233
|
```bash
|
|
234
|
-
work.py next # ONE task,
|
|
234
|
+
work.py next # ONE task, taken, with its read order printed
|
|
235
235
|
# … build it, then EITHER prove it:
|
|
236
236
|
work.py verify --task <name>
|
|
237
237
|
work.py observed <name> --ac AC-01 --saw "what you actually saw"
|
|
@@ -196,12 +196,6 @@
|
|
|
196
196
|
"maximum": 3,
|
|
197
197
|
"default": 2,
|
|
198
198
|
"description": "The highest task `tier:` an unattended run acts on alone. 0 reversible and local · 1 ordinary change behind existing tests · 2 new behaviour or a reversible migration · 3 irreversible, or it touches money, secrets, personal data or the law. Default 2: the shift does ordinary work and parks the rest with `work ask`. 3 means \"decide everything\" and is deliberately never the shipped default."
|
|
199
|
-
},
|
|
200
|
-
"lease_minutes": {
|
|
201
|
-
"type": "integer",
|
|
202
|
-
"minimum": 1,
|
|
203
|
-
"default": 240,
|
|
204
|
-
"description": "How long one instance's claim on a task holds before another may take it. Long enough that real work finishes inside it; short enough that a crashed shift's task is takeable the same day."
|
|
205
199
|
}
|
|
206
200
|
}
|
|
207
201
|
},
|