@appchy/jarvis 0.1.119 → 0.1.121
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 +44 -8
- package/dist/bin.js.map +1 -1
- package/harness/harness/gate.py +374 -82
- package/harness/harness/git.py +121 -8
- package/harness/presets/appchy/PRESET.md +1 -1
- package/harness/test_work.py +360 -2
- package/harness/work.py +14 -7
- package/package.json +3 -3
package/harness/harness/git.py
CHANGED
|
@@ -502,6 +502,109 @@ def changed(repo) -> tuple:
|
|
|
502
502
|
return paths, untracked
|
|
503
503
|
|
|
504
504
|
|
|
505
|
+
#: The buckets a task folder sits in. A path's item is the segment AFTER one of
|
|
506
|
+
#: these, which is how a file is traced back to the work it belongs to without a
|
|
507
|
+
#: registry that could disagree with the tree.
|
|
508
|
+
_BUCKETS = ("queue", "in-progress", "blocked", "complete")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def dirty(repo) -> frozenset:
|
|
512
|
+
"""What is already uncommitted under the configured paths, right now.
|
|
513
|
+
|
|
514
|
+
Taken before a command runs so the commit afterwards can tell what that command
|
|
515
|
+
wrote from what was lying there when it started. Cheap — one `git status`.
|
|
516
|
+
"""
|
|
517
|
+
if not is_repo(repo):
|
|
518
|
+
return frozenset()
|
|
519
|
+
return frozenset(changed(repo)[0])
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def item_of(path: str) -> str:
|
|
523
|
+
"""The work item a path belongs to, or "" for anything that is not a task file.
|
|
524
|
+
|
|
525
|
+
Governance docs, a version.md and an epic.md all answer "" — they belong to the
|
|
526
|
+
tree rather than to one item, so nothing can claim to have authored them by
|
|
527
|
+
holding something.
|
|
528
|
+
"""
|
|
529
|
+
parts = Path(path).parts
|
|
530
|
+
for i, part in enumerate(parts[:-1]):
|
|
531
|
+
# A cut holds buckets and the item is the folder inside one. The backlog
|
|
532
|
+
# holds no buckets at either tier — it is `backlog/<epic>/<item>/` — so the
|
|
533
|
+
# item sits one deeper, and reading it as a bucket would name the epic.
|
|
534
|
+
step = 2 if part == "backlog" else (1 if part in _BUCKETS else 0)
|
|
535
|
+
if step and i + step < len(parts) - 1:
|
|
536
|
+
return parts[i + step]
|
|
537
|
+
return ""
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def authored(paths, before, item: str) -> tuple:
|
|
541
|
+
"""Split what is uncommitted into (mine, theirs).
|
|
542
|
+
|
|
543
|
+
MINE is what this command wrote — anything that became dirty while it ran — plus
|
|
544
|
+
everything under the item it acted on, because a session that holds an item and
|
|
545
|
+
hand-edits its brief did author that, whenever it typed it.
|
|
546
|
+
|
|
547
|
+
THEIRS is the rest, and it is left alone. It used to be committed too, under
|
|
548
|
+
whoever ran next: the pathspec took the whole of `work/`, so a peer's
|
|
549
|
+
half-written prose landed inside somebody else's commit, stamped with their
|
|
550
|
+
session and their item. That made the record assert two things that were not
|
|
551
|
+
true — who wrote it, and what it was work on — and both are read downstream to
|
|
552
|
+
answer who is on an item.
|
|
553
|
+
|
|
554
|
+
What this deliberately does NOT do is narrow the pathspec to the command's own
|
|
555
|
+
writes alone. A brief edited by hand before the command that files it would then
|
|
556
|
+
never be committed by anything, which is the hole that made the sweep the right
|
|
557
|
+
answer in the first place.
|
|
558
|
+
"""
|
|
559
|
+
mine, theirs = [], []
|
|
560
|
+
for p in paths:
|
|
561
|
+
if p not in before or (item and item_of(p) == item):
|
|
562
|
+
mine.append(p)
|
|
563
|
+
else:
|
|
564
|
+
theirs.append(p)
|
|
565
|
+
return mine, theirs
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _left_alone(theirs) -> str:
|
|
569
|
+
"""What to say about work this commit did not take.
|
|
570
|
+
|
|
571
|
+
Named file by file with the item each belongs to, because "some files were left"
|
|
572
|
+
is not something a session can act on and this is meant to be resolved in the
|
|
573
|
+
turn it appears, not noted. A session reading it either commits them — they are
|
|
574
|
+
often its own, typed before the command that triggered this — or leaves them to
|
|
575
|
+
the session working that item, which is now a decision somebody makes rather
|
|
576
|
+
than one the pathspec made for them.
|
|
577
|
+
"""
|
|
578
|
+
if not theirs:
|
|
579
|
+
return ""
|
|
580
|
+
lines = []
|
|
581
|
+
for p in sorted(theirs)[:10]:
|
|
582
|
+
owner = item_of(p)
|
|
583
|
+
lines.append(f" {p}" + (f" ({owner})" if owner else " (no item — a hand edit)"))
|
|
584
|
+
more = len(theirs) - len(lines)
|
|
585
|
+
if more > 0:
|
|
586
|
+
lines.append(f" … and {more} more")
|
|
587
|
+
return ("left uncommitted — this write did not author them, so it did not take "
|
|
588
|
+
"them:\n" + "\n".join(lines) + "\n Commit them yourself if they are "
|
|
589
|
+
"yours; if another session is working that item, they are that session's "
|
|
590
|
+
"to land.")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def has_commit(repo, sha: str) -> bool:
|
|
594
|
+
"""Is `sha` still a commit this repo holds?
|
|
595
|
+
|
|
596
|
+
A push that rebases rewrites the commits it moves, so a sha a gate run recorded
|
|
597
|
+
can stop existing while the code it measured is untouched. Everything that reads
|
|
598
|
+
a recorded sha needs to tell that apart from the code having changed: they are
|
|
599
|
+
opposite situations and the first one cannot be diagnosed from a hash a reader
|
|
600
|
+
cannot open.
|
|
601
|
+
"""
|
|
602
|
+
if not sha or sha == "no-git":
|
|
603
|
+
return False
|
|
604
|
+
code, _, _ = _git(repo, "cat-file", "-e", f"{sha}^{{commit}}")
|
|
605
|
+
return code == 0
|
|
606
|
+
|
|
607
|
+
|
|
505
608
|
def only_board_moved(repo, sha: str) -> bool:
|
|
506
609
|
"""Is everything between `sha` and HEAD a board file?
|
|
507
610
|
|
|
@@ -572,12 +675,17 @@ def moved_only_elsewhere(repo, sha: str, root, regions: list) -> bool:
|
|
|
572
675
|
return moved
|
|
573
676
|
|
|
574
677
|
|
|
575
|
-
def land(repo, item: str, rows: list) -> tuple:
|
|
576
|
-
"""Commit what
|
|
678
|
+
def land(repo, item: str, rows: list, before=frozenset()) -> tuple:
|
|
679
|
+
"""Commit what this command wrote and push it. Returns (committed, pushed, note).
|
|
680
|
+
|
|
681
|
+
`before` is what was already uncommitted when the command started, so the commit
|
|
682
|
+
can take what it authored and leave what it did not — see `authored`. Passing
|
|
683
|
+
nothing keeps the old behaviour of taking everything, which is right for a caller
|
|
684
|
+
that has no earlier moment to compare against.
|
|
577
685
|
|
|
578
|
-
`note` is what to tell the caller — empty when everything landed
|
|
579
|
-
cannot land is NOT a failure of the write: the change is
|
|
580
|
-
what makes it unloseable, and the note says how to send it.
|
|
686
|
+
`note` is what to tell the caller — empty when everything landed and nothing was
|
|
687
|
+
left behind. A push that cannot land is NOT a failure of the write: the change is
|
|
688
|
+
committed, which is what makes it unloseable, and the note says how to send it.
|
|
581
689
|
"""
|
|
582
690
|
# Asked FIRST, and not after `changed`: a directory that is not a repo makes
|
|
583
691
|
# `git status` fail, `changed` return nothing, and the write look like a no-op
|
|
@@ -590,6 +698,9 @@ def land(repo, item: str, rows: list) -> tuple:
|
|
|
590
698
|
# network must not be able to stall every other board write on this machine.
|
|
591
699
|
with _hold(repo):
|
|
592
700
|
paths, untracked = changed(repo)
|
|
701
|
+
paths, theirs = authored(paths, before, item)
|
|
702
|
+
untracked = [p for p in untracked if p in set(paths)]
|
|
703
|
+
carried = _left_alone(theirs)
|
|
593
704
|
if not paths:
|
|
594
705
|
# Nothing to commit is usually the ordinary answer — a read, or a write
|
|
595
706
|
# that changed nothing — and says nothing. Two cases are not ordinary.
|
|
@@ -610,7 +721,7 @@ def land(repo, item: str, rows: list) -> tuple:
|
|
|
610
721
|
return False, False, (
|
|
611
722
|
"carried by a board write that committed a moment earlier — the "
|
|
612
723
|
"change is in git, under that commit's item rather than this one")
|
|
613
|
-
return False, False,
|
|
724
|
+
return False, False, carried
|
|
614
725
|
|
|
615
726
|
if untracked:
|
|
616
727
|
code, _, err = _locking(repo, "add", "--", *untracked)
|
|
@@ -626,9 +737,11 @@ def land(repo, item: str, rows: list) -> tuple:
|
|
|
626
737
|
return False, False, f"could not commit the board write: {_tail(err)}"
|
|
627
738
|
|
|
628
739
|
if not GIT.get("push"):
|
|
629
|
-
return True, False,
|
|
740
|
+
return True, False, carried
|
|
630
741
|
pushed, why = _push(repo)
|
|
631
|
-
|
|
742
|
+
# Both can be true at once: a push that did not land AND work left for somebody
|
|
743
|
+
# else. Neither is allowed to hide the other.
|
|
744
|
+
return True, pushed, SEP.join(x for x in (why, carried) if x) if (why and carried) else (why or carried)
|
|
632
745
|
|
|
633
746
|
|
|
634
747
|
def _push(repo) -> tuple:
|
|
@@ -441,7 +441,7 @@ the only way forward has hit a real gap: **park a question, do not shell out.**
|
|
|
441
441
|
| Create a task | `jarvis work new <name> --epic <e> [--priority] [--depends] [--owner] [--code] [--covers]` | either — `work_create` is the tool |
|
|
442
442
|
| List | `jarvis work list` | either — `work_list` is the tool |
|
|
443
443
|
| Pick up / complete | `jarvis work move <name> in-progress\|complete --delivered "…" --not-included "…"` | either — `work_take` / `work_complete` |
|
|
444
|
-
| Prove it | `jarvis work verify
|
|
444
|
+
| Prove it | `jarvis work verify <name>` (blocks) · `--start` (what the tool uses) · `--status` (asks, starts nothing) | either — `work_verify` |
|
|
445
445
|
| Record an approved plan | `jarvis work plan <name> [--file <path>]` | either |
|
|
446
446
|
| Hand off | `jarvis work handoff <name>`, then fill it | either — **no tool yet**; the prose is judgement |
|
|
447
447
|
| Open the NEXT session | `jarvis work kickoff <name>` — never hand-write the prompt | either — `work_wrap` embeds it |
|
package/harness/test_work.py
CHANGED
|
@@ -3248,13 +3248,101 @@ def test_starting_while_a_run_is_live_names_whose_it_is_rather_than_attaching():
|
|
|
3248
3248
|
"machine": "here", "started": gate._now().isoformat(),
|
|
3249
3249
|
"gates": ["tests"], "current": "tests", "done": [],
|
|
3250
3250
|
"finished": None, "read": False})
|
|
3251
|
-
out = _said(lambda: gate.cmd_verify({"task": "beta", "
|
|
3252
|
-
|
|
3251
|
+
out = _said(lambda: gate.cmd_verify({"task": "beta", "start": True}))
|
|
3252
|
+
# `busy` and not `running`: the slot is taken and it is somebody
|
|
3253
|
+
# else's. Reporting both as `running` is how a session read a held
|
|
3254
|
+
# checkout as its own gates making progress.
|
|
3255
|
+
assert out["state"] == "busy", out
|
|
3256
|
+
assert out["run"]["task"] == "alpha", out
|
|
3253
3257
|
assert "alpha" in out["message"], out["message"]
|
|
3258
|
+
# Not a queue, and it says so rather than letting a refused caller
|
|
3259
|
+
# believe a call is coming.
|
|
3260
|
+
assert "Nothing is holding your place" in out["message"], out["message"]
|
|
3254
3261
|
finally:
|
|
3255
3262
|
gate.VERIFY = old
|
|
3256
3263
|
|
|
3257
3264
|
|
|
3265
|
+
def test_a_held_slot_never_reports_as_the_callers_own_progress():
|
|
3266
|
+
# The measured half of this: asking for progress answered `18s, 0 of 9` and then
|
|
3267
|
+
# `322s, 8 of 9` two minutes apart, for two different runs, and a caller could
|
|
3268
|
+
# tell neither a fresh start from somebody else's long one nor whether its own
|
|
3269
|
+
# request had taken effect. Every gate-shaped field describes the CALLER's item,
|
|
3270
|
+
# so on a slot that is not theirs they are empty and the run sits on its own.
|
|
3271
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3272
|
+
v = _tree(tmp)
|
|
3273
|
+
e = _epic(v, "an-epic")
|
|
3274
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
3275
|
+
_task(e / "in-progress", "beta", body="# T\n")
|
|
3276
|
+
with _work_dir(tmp) as root:
|
|
3277
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true", "lint": "true"}
|
|
3278
|
+
try:
|
|
3279
|
+
gate._write_run(root, {"task": "alpha", "pid": os.getpid(),
|
|
3280
|
+
"machine": "here", "session": peers.me(),
|
|
3281
|
+
"started": gate._now().isoformat(),
|
|
3282
|
+
"gates": ["lint", "tests"], "current": "tests",
|
|
3283
|
+
"done": ["lint"], "finished": None, "read": False})
|
|
3284
|
+
out = _said(lambda: gate.cmd_verify({"task": "beta", "status": True}))
|
|
3285
|
+
assert out["state"] == "busy", out
|
|
3286
|
+
assert out["gate"] == "" and out["done"] == [] and out["gates"] == [], \
|
|
3287
|
+
"another item's progress was reported as this caller's"
|
|
3288
|
+
assert out["elapsedSeconds"] == 0 and out["passed"] is False, out
|
|
3289
|
+
# …and what IS true about the checkout is said, in one place.
|
|
3290
|
+
assert out["run"]["task"] == "alpha", out
|
|
3291
|
+
assert out["run"]["gate"] == "tests" and out["run"]["done"] == ["lint"], out
|
|
3292
|
+
assert "'beta'" in out["message"], out["message"]
|
|
3293
|
+
|
|
3294
|
+
# The same run, asked about by the item it IS proving, is progress.
|
|
3295
|
+
mine = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
3296
|
+
assert mine["state"] == "running", mine
|
|
3297
|
+
assert mine["done"] == ["lint"], mine
|
|
3298
|
+
finally:
|
|
3299
|
+
gate.VERIFY = old
|
|
3300
|
+
|
|
3301
|
+
|
|
3302
|
+
def test_a_refused_verify_names_a_reachable_holder_when_there_is_one():
|
|
3303
|
+
# The sibling refusal's join, made for the completion gate, applied to the one
|
|
3304
|
+
# mechanism in this epic that actually refuses: the id was always in the run file
|
|
3305
|
+
# and led nowhere, so the only move left was to ask again on a timer.
|
|
3306
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3307
|
+
v = _tree(tmp)
|
|
3308
|
+
e = _epic(v, "an-epic")
|
|
3309
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
3310
|
+
with _work_dir(tmp) as root, tempfile.TemporaryDirectory() as sess:
|
|
3311
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3312
|
+
keep = os.environ.get(peers.SESSIONS)
|
|
3313
|
+
os.environ[peers.SESSIONS] = sess
|
|
3314
|
+
peers._RUNNING.clear()
|
|
3315
|
+
try:
|
|
3316
|
+
Path(sess, "1.json").write_text(json.dumps(
|
|
3317
|
+
{"sessionId": "aaaabbbb-0000-0000-0000-000000000000",
|
|
3318
|
+
"name": "jarvis-22", "pid": os.getpid(), "cwd": str(root.parent)}))
|
|
3319
|
+
gate._write_run(root, {
|
|
3320
|
+
"task": "alpha", "pid": os.getpid(), "machine": peers.here(),
|
|
3321
|
+
"session": "aaaabbbb-0000-0000-0000-000000000000",
|
|
3322
|
+
"started": gate._now().isoformat(), "gates": ["tests"],
|
|
3323
|
+
"current": "tests", "done": [], "finished": None, "read": False})
|
|
3324
|
+
said = gate._busy(gate.live_run(root))
|
|
3325
|
+
assert "jarvis-22" in said, said
|
|
3326
|
+
assert "SendMessage" in said, said
|
|
3327
|
+
# How far through, because "started 190s ago" cannot answer whether
|
|
3328
|
+
# this is nearly over or has just begun.
|
|
3329
|
+
assert "gate 1 of 1" in said, said
|
|
3330
|
+
|
|
3331
|
+
# A run that recorded no session says nothing about who — an
|
|
3332
|
+
# enrichment we can lose, never a guess we invent.
|
|
3333
|
+
gate._write_run(root, {**gate.read_run(root), "session": ""})
|
|
3334
|
+
quiet = gate._busy(gate.live_run(root))
|
|
3335
|
+
assert "jarvis-22" not in quiet and "started by" not in quiet, quiet
|
|
3336
|
+
assert "corrupt each other" in quiet, quiet
|
|
3337
|
+
finally:
|
|
3338
|
+
gate.VERIFY = old
|
|
3339
|
+
peers._RUNNING.clear()
|
|
3340
|
+
if keep is None:
|
|
3341
|
+
os.environ.pop(peers.SESSIONS, None)
|
|
3342
|
+
else:
|
|
3343
|
+
os.environ[peers.SESSIONS] = keep
|
|
3344
|
+
|
|
3345
|
+
|
|
3258
3346
|
def test_a_finished_run_for_another_task_is_not_handed_over_as_this_ones():
|
|
3259
3347
|
# The wrong-verdict half. A result proven for one item says nothing about
|
|
3260
3348
|
# another, and reporting it would put a green gate on work nobody ran.
|
|
@@ -3271,8 +3359,193 @@ def test_a_finished_run_for_another_task_is_not_handed_over_as_this_ones():
|
|
|
3271
3359
|
out = _said(lambda: gate.cmd_verify({"task": "beta", "status": True}))
|
|
3272
3360
|
assert out["state"] == "idle", out
|
|
3273
3361
|
assert out["passed"] is False, out
|
|
3362
|
+
# …and it says WHICH kind of nothing this is. "No gates have run"
|
|
3363
|
+
# covering both "nobody ran anything" and "the last run proved
|
|
3364
|
+
# somebody else's item" is what a caller cannot act on.
|
|
3365
|
+
assert out["reason"] == "other", out
|
|
3366
|
+
assert "'alpha'" in out["message"], out["message"]
|
|
3367
|
+
finally:
|
|
3368
|
+
gate.VERIFY = old
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
def test_a_run_that_stopped_without_recording_says_so_rather_than_reading_as_never_run():
|
|
3372
|
+
# The half nobody could prove. A run whose process is gone leaves a file every
|
|
3373
|
+
# door correctly treats as absent, and the next start OVERWRITES it — so the
|
|
3374
|
+
# session that lost four minutes of gates learned it only by noticing the slot
|
|
3375
|
+
# now named somebody else's item. Reported from the receiving end, 2026-09-12.
|
|
3376
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3377
|
+
v = _tree(tmp)
|
|
3378
|
+
e = _epic(v, "an-epic")
|
|
3379
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
3380
|
+
_task(e / "in-progress", "beta", body="# T\n")
|
|
3381
|
+
with _work_dir(tmp) as root:
|
|
3382
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3383
|
+
try:
|
|
3384
|
+
dead = {"task": "alpha", "pid": 2 ** 31 - 1, "machine": "here",
|
|
3385
|
+
"session": "aaaabbbb-0000-0000-0000-000000000000",
|
|
3386
|
+
"started": gate._now().isoformat(),
|
|
3387
|
+
"gates": ["lint", "tests"], "current": "tests",
|
|
3388
|
+
"done": ["lint"], "finished": None, "read": False}
|
|
3389
|
+
gate._write_run(root, dead)
|
|
3390
|
+
out = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
3391
|
+
assert out["state"] == "idle" and out["reason"] == "lost", out
|
|
3392
|
+
assert "gate 2 of 2" in out["message"], out["message"]
|
|
3393
|
+
assert "without recording" in out["message"], out["message"]
|
|
3394
|
+
|
|
3395
|
+
# And it survives the run that replaces it, which is the case that
|
|
3396
|
+
# actually happened: the slot is claimed by another item, and the
|
|
3397
|
+
# session whose gates vanished is still told — being behind a
|
|
3398
|
+
# stranger's run and having lost your own are two facts, and the
|
|
3399
|
+
# only thing it had to go on was the slot naming somebody else.
|
|
3400
|
+
gate._write_run(root, gate._begin(root, "beta", os.getpid()))
|
|
3401
|
+
assert gate.read_run(root)["lost"]["task"] == "alpha", gate.read_run(root)
|
|
3402
|
+
still = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
3403
|
+
assert still["state"] == "busy" and still["reason"] == "lost", still
|
|
3404
|
+
assert still["run"]["task"] == "beta", still
|
|
3405
|
+
assert "without recording" in still["message"], still["message"]
|
|
3406
|
+
|
|
3407
|
+
# A run that FINISHED is not a lost one, however long ago it was.
|
|
3408
|
+
gate._write_run(root, {**dead, "finished": gate._now().isoformat(),
|
|
3409
|
+
"passed": True, "sha": "", "results": []})
|
|
3410
|
+
done = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
3411
|
+
assert done["state"] == "finished", done
|
|
3412
|
+
finally:
|
|
3413
|
+
gate.VERIFY = old
|
|
3414
|
+
|
|
3415
|
+
|
|
3416
|
+
def test_a_result_the_code_moved_past_names_both_commits():
|
|
3417
|
+
# `moved` and `no gates have run` were one sentence, and this is the mode that
|
|
3418
|
+
# cost the most: a gate run only counted if nobody committed for four minutes,
|
|
3419
|
+
# and three sessions were committing every few minutes. The result was recorded
|
|
3420
|
+
# against a HEAD that no longer existed and read as never having run.
|
|
3421
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3422
|
+
try:
|
|
3423
|
+
repo = _git_repo(tmp, push=False)
|
|
3424
|
+
ran = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
3425
|
+
(repo / "src.txt").write_text("the code moved\n")
|
|
3426
|
+
_git(repo, "add", "-A"); _git(repo, "commit", "-qm", "somebody committed")
|
|
3427
|
+
head = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
3428
|
+
|
|
3429
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3430
|
+
with _work_dir(str(repo / "work")) as root:
|
|
3431
|
+
try:
|
|
3432
|
+
gate._write_run(root, {
|
|
3433
|
+
"task": "alpha", "pid": os.getpid(), "machine": "here",
|
|
3434
|
+
"session": "", "started": gate._now().isoformat(),
|
|
3435
|
+
"head": ran, "gates": ["tests"], "current": "",
|
|
3436
|
+
"done": ["tests"], "finished": gate._now().isoformat(),
|
|
3437
|
+
"passed": True, "sha": ran, "read": False,
|
|
3438
|
+
"results": [{"name": "tests", "status": "PASS", "said": ""}]})
|
|
3439
|
+
out = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
3440
|
+
assert out["reason"] == "moved", out
|
|
3441
|
+
assert ran[:12] in out["message"], out["message"]
|
|
3442
|
+
assert head[:12] in out["message"], out["message"]
|
|
3443
|
+
assert out["passed"] is False, "a stale green must not read as proven"
|
|
3444
|
+
|
|
3445
|
+
# A commit a push rebased away is a DIFFERENT answer: the code it
|
|
3446
|
+
# measured may be identical, and naming a hash nobody can open is
|
|
3447
|
+
# worse than saying it is gone.
|
|
3448
|
+
gate._write_run(root, {**gate.read_run(root), "read": False,
|
|
3449
|
+
"sha": "0" * 40, "head": "0" * 40})
|
|
3450
|
+
gone = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
|
|
3451
|
+
assert gone["reason"] == "gone", gone
|
|
3452
|
+
assert "rebased" in gone["message"], gone["message"]
|
|
3453
|
+
finally:
|
|
3454
|
+
gate.VERIFY = old
|
|
3455
|
+
finally:
|
|
3456
|
+
config.apply(config.DEFAULTS)
|
|
3457
|
+
|
|
3458
|
+
|
|
3459
|
+
def test_the_slot_is_held_from_before_the_child_starts():
|
|
3460
|
+
# The check-then-spawn this replaces left the slot unheld for the whole of a
|
|
3461
|
+
# Python interpreter's startup — hundreds of milliseconds, not an instant — so
|
|
3462
|
+
# two sessions polling one board could both walk through it and the second run's
|
|
3463
|
+
# build would delete the artifact the first one's log check was about to read.
|
|
3464
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3465
|
+
v = _tree(tmp)
|
|
3466
|
+
e = _epic(v, "an-epic")
|
|
3467
|
+
_task(e / "in-progress", "alpha", body="# T\n")
|
|
3468
|
+
with _work_dir(tmp) as root:
|
|
3469
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3470
|
+
spawned = []
|
|
3471
|
+
real = gate.subprocess.Popen
|
|
3472
|
+
try:
|
|
3473
|
+
# The claim has to be in place BEFORE anything is spawned, or a lost
|
|
3474
|
+
# race still leaves a child running gates nothing is tracking. Only
|
|
3475
|
+
# the verify child is intercepted — everything else here, `git`
|
|
3476
|
+
# included, still runs for real.
|
|
3477
|
+
class Sham:
|
|
3478
|
+
pid = os.getpid()
|
|
3479
|
+
|
|
3480
|
+
def watch(argv, **kw):
|
|
3481
|
+
if "--detached" not in argv:
|
|
3482
|
+
return real(argv, **kw)
|
|
3483
|
+
spawned.append(gate.live_run(root))
|
|
3484
|
+
return Sham()
|
|
3485
|
+
gate.subprocess.Popen = watch
|
|
3486
|
+
out = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
|
|
3487
|
+
assert out["state"] == "started", out
|
|
3488
|
+
assert spawned and spawned[0] and spawned[0]["task"] == "alpha", \
|
|
3489
|
+
"the slot was open while the child was being spawned"
|
|
3490
|
+
|
|
3491
|
+
# A second start over a live claim is refused, and starts nothing.
|
|
3492
|
+
spawned.clear()
|
|
3493
|
+
again = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
|
|
3494
|
+
assert again["state"] == "busy", again
|
|
3495
|
+
assert spawned == [], "a refused start spawned a run anyway"
|
|
3496
|
+
|
|
3497
|
+
# A slot whose process is gone is taken over rather than waited for:
|
|
3498
|
+
# a lock that outlives its holder strands every future verify.
|
|
3499
|
+
gate._write_run(root, {**gate.read_run(root), "pid": 2 ** 31 - 1})
|
|
3500
|
+
free = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
|
|
3501
|
+
assert free["state"] == "started", free
|
|
3502
|
+
finally:
|
|
3503
|
+
gate.subprocess.Popen = real
|
|
3504
|
+
gate.VERIFY = old
|
|
3505
|
+
|
|
3506
|
+
|
|
3507
|
+
def test_a_pid_somebody_else_owns_is_alive_not_dead():
|
|
3508
|
+
# Two readings of one pid: `peers._live` treats a kernel refusal as alive and
|
|
3509
|
+
# this treated it as dead, so a run owned by another user read as over and a
|
|
3510
|
+
# second run could start over a live one. One question, one answer.
|
|
3511
|
+
real = gate.os.kill
|
|
3512
|
+
try:
|
|
3513
|
+
def refuse(pid, sig):
|
|
3514
|
+
raise PermissionError(1, "Operation not permitted")
|
|
3515
|
+
gate.os.kill = refuse
|
|
3516
|
+
assert gate._alive(4242) is True
|
|
3517
|
+
finally:
|
|
3518
|
+
gate.os.kill = real
|
|
3519
|
+
# pid 0 asks about the whole process group and a negative one about another
|
|
3520
|
+
# group, so either would answer "alive" about something that is not this run.
|
|
3521
|
+
assert gate._alive(0) is False and gate._alive(-1) is False
|
|
3522
|
+
|
|
3523
|
+
|
|
3524
|
+
def test_the_doors_that_start_are_told_apart_from_the_one_that_asks():
|
|
3525
|
+
# `--async` only ever started a run, and it READ like "ask me in the background":
|
|
3526
|
+
# a session polling with it set a fresh nine-gate run going every time the
|
|
3527
|
+
# previous one ended. The parser turns any `--word` into a flag, so an unread one
|
|
3528
|
+
# fell through to the BLOCKING door — the caller waiting minutes for a run it
|
|
3529
|
+
# never asked to start. A typo and a renamed flag both say so now.
|
|
3530
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3531
|
+
v = _tree(tmp)
|
|
3532
|
+
_epic(v, "an-epic")
|
|
3533
|
+
with _work_dir(tmp):
|
|
3534
|
+
old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
|
|
3535
|
+
try:
|
|
3536
|
+
for flag in ("async", "stat", "wait"):
|
|
3537
|
+
try:
|
|
3538
|
+
gate.cmd_verify({"task": "alpha", flag: True})
|
|
3539
|
+
except SystemExit as e:
|
|
3540
|
+
assert e.code, f"--{flag} exited 0"
|
|
3541
|
+
else:
|
|
3542
|
+
raise AssertionError(f"--{flag} was accepted and ignored")
|
|
3274
3543
|
finally:
|
|
3275
3544
|
gate.VERIFY = old
|
|
3545
|
+
# The help names each door by whether it STARTS, which is the only distinction
|
|
3546
|
+
# that matters when one of them costs four minutes.
|
|
3547
|
+
help_ = entry.__doc__ or ""
|
|
3548
|
+
assert "--start" in help_ and "--status only SAYS" in help_, help_
|
|
3276
3549
|
|
|
3277
3550
|
|
|
3278
3551
|
def test_a_second_verify_is_refused_while_one_is_running():
|
|
@@ -3832,6 +4105,91 @@ def _board_write(repo, item, rows):
|
|
|
3832
4105
|
return git.land(repo, item, rows)
|
|
3833
4106
|
|
|
3834
4107
|
|
|
4108
|
+
def test_a_write_does_not_commit_prose_another_session_left_in_the_tree():
|
|
4109
|
+
# The defect, reproduced: two sessions in one checkout, each with an uncommitted
|
|
4110
|
+
# edit, and one of them runs a board command. The pathspec used to take the whole
|
|
4111
|
+
# of `work/`, so the peer's prose landed inside this commit — stamped with this
|
|
4112
|
+
# session and this item, which are both wrong, and both are read downstream to
|
|
4113
|
+
# answer who is on what.
|
|
4114
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
4115
|
+
try:
|
|
4116
|
+
repo = _git_repo(tmp, push=False)
|
|
4117
|
+
versions = repo / "work" / "versions" / "01-a-cut" / "an-epic"
|
|
4118
|
+
mine = versions / "in-progress" / "mine"
|
|
4119
|
+
theirs = versions / "in-progress" / "theirs"
|
|
4120
|
+
for folder in (mine, theirs):
|
|
4121
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
4122
|
+
(folder / "task.md").write_text("# a brief\n")
|
|
4123
|
+
_git(repo, "add", "-A")
|
|
4124
|
+
_git(repo, "commit", "-qm", "two items")
|
|
4125
|
+
|
|
4126
|
+
# Both sessions type prose. Neither has committed; only one runs a command.
|
|
4127
|
+
(mine / "task.md").write_text("# a brief\n\nwhat I wrote.\n")
|
|
4128
|
+
(theirs / "task.md").write_text("# a brief\n\nwhat THEY wrote.\n")
|
|
4129
|
+
|
|
4130
|
+
before = git.dirty(repo)
|
|
4131
|
+
committed, _, note = git.land(
|
|
4132
|
+
repo, "mine", [{"event": "moved", "name": "mine", "to": "complete"}],
|
|
4133
|
+
before)
|
|
4134
|
+
assert committed, "the board write must still land"
|
|
4135
|
+
|
|
4136
|
+
body = _git(repo, "show", "--stat", "--format=%B", "HEAD").stdout
|
|
4137
|
+
assert "mine/task.md" in body, body
|
|
4138
|
+
# The whole point: their file is not in this commit, and is still dirty.
|
|
4139
|
+
assert "theirs/task.md" not in body, body
|
|
4140
|
+
assert "theirs/task.md" in _git(repo, "status", "--porcelain").stdout
|
|
4141
|
+
|
|
4142
|
+
# And the session is told, by name, so it resolves it in this turn rather
|
|
4143
|
+
# than discovering it by reading a trailer later.
|
|
4144
|
+
assert "theirs/task.md" in note, note
|
|
4145
|
+
assert "theirs" in note, note
|
|
4146
|
+
finally:
|
|
4147
|
+
events._PENDING.clear()
|
|
4148
|
+
config.apply(config.DEFAULTS)
|
|
4149
|
+
|
|
4150
|
+
|
|
4151
|
+
def test_a_write_still_commits_prose_this_session_typed_into_its_own_brief():
|
|
4152
|
+
# The other half, and the reason this is not just a narrower pathspec: a session
|
|
4153
|
+
# hand-edits the brief of the item it holds and then files it. That prose was
|
|
4154
|
+
# authored here, whenever it was typed, and a commit that left it behind would
|
|
4155
|
+
# recreate the hole the sweep was built to close — work in no commit at all.
|
|
4156
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
4157
|
+
try:
|
|
4158
|
+
repo = _git_repo(tmp, push=False)
|
|
4159
|
+
folder = repo / "work" / "versions" / "01-a-cut" / "an-epic" / "in-progress" / "mine"
|
|
4160
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
4161
|
+
(folder / "task.md").write_text("# a brief\n")
|
|
4162
|
+
_git(repo, "add", "-A")
|
|
4163
|
+
_git(repo, "commit", "-qm", "an item")
|
|
4164
|
+
|
|
4165
|
+
# Typed BEFORE the command runs — so it is in `before`, and only the
|
|
4166
|
+
# item it belongs to makes it this session's.
|
|
4167
|
+
(folder / "task.md").write_text("# a brief\n\nwhat I wrote by hand.\n")
|
|
4168
|
+
before = git.dirty(repo)
|
|
4169
|
+
|
|
4170
|
+
committed, _, note = git.land(
|
|
4171
|
+
repo, "mine", [{"event": "moved", "name": "mine", "to": "complete"}],
|
|
4172
|
+
before)
|
|
4173
|
+
assert committed
|
|
4174
|
+
assert "mine/task.md" in _git(repo, "show", "--stat", "--format=%B", "HEAD").stdout
|
|
4175
|
+
assert not _git(repo, "status", "--porcelain").stdout.strip(), "nothing left behind"
|
|
4176
|
+
assert note == "", note
|
|
4177
|
+
finally:
|
|
4178
|
+
events._PENDING.clear()
|
|
4179
|
+
config.apply(config.DEFAULTS)
|
|
4180
|
+
|
|
4181
|
+
|
|
4182
|
+
def test_a_path_says_which_item_it_belongs_to_and_governance_says_none():
|
|
4183
|
+
# What decides authorship, so it is worth pinning: a task file answers its item,
|
|
4184
|
+
# and a governance doc answers nothing rather than being claimed by whoever holds
|
|
4185
|
+
# an item at the time.
|
|
4186
|
+
assert git.item_of("work/versions/01-a-cut/an-epic/in-progress/alpha/task.md") == "alpha"
|
|
4187
|
+
assert git.item_of("work/versions/01-a-cut/an-epic/queue/beta/handoff.md") == "beta"
|
|
4188
|
+
assert git.item_of("work/backlog/an-epic/gamma/task.md") == "gamma"
|
|
4189
|
+
assert git.item_of("work/architecture/data.md") == ""
|
|
4190
|
+
assert git.item_of("work/versions/01-a-cut/an-epic/epic.md") == ""
|
|
4191
|
+
|
|
4192
|
+
|
|
3835
4193
|
def test_git_is_off_by_default_so_a_shared_consumer_is_untouched():
|
|
3836
4194
|
# The harness is shared with repos that never asked for any of this. A default
|
|
3837
4195
|
# that committed on their behalf would be the same class of surprise as a hook
|
package/harness/work.py
CHANGED
|
@@ -91,10 +91,13 @@ cannot finish has somewhere to put the reason instead of stalling or guessing:
|
|
|
91
91
|
status what shipped · what waits on you · what is at risk
|
|
92
92
|
method the METHOD in full — how work is done here. The session
|
|
93
93
|
block names it; this is the body, on request.
|
|
94
|
-
verify
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
verify <name> [--start | --status] RUN verify.* (shell=False) and record the result.
|
|
95
|
+
Which door you pick decides whether a run STARTS:
|
|
96
|
+
(no flag) starts them here and waits for the table
|
|
97
|
+
--start starts them in the background, answers at once
|
|
98
|
+
--status only SAYS where the run stands — starts nothing
|
|
99
|
+
One run at a time per checkout: two corrupt each
|
|
100
|
+
other. Refused while one is going, and told whose.
|
|
98
101
|
observed <task> --ac AC-01 --saw "…" an eyes-on, for what a test cannot prove
|
|
99
102
|
ask <task> --question "…" [--options "a | b"] [--owner who] [--durable]
|
|
100
103
|
park a question, move on — exits 0, never blocks
|
|
@@ -297,6 +300,10 @@ def main() -> int:
|
|
|
297
300
|
root = locate_work_root()[0]
|
|
298
301
|
repo = root.parent if root else _project_root(flags)
|
|
299
302
|
_say(git.refresh(repo))
|
|
303
|
+
# What was already uncommitted before this command ran. The commit afterwards
|
|
304
|
+
# takes what it wrote and leaves what it found, so a peer's half-written prose
|
|
305
|
+
# is no longer committed under this session's name and this command's item.
|
|
306
|
+
before = git.dirty(repo)
|
|
300
307
|
try:
|
|
301
308
|
return dispatch(cmd, pos, flags, cfg)
|
|
302
309
|
finally:
|
|
@@ -310,7 +317,7 @@ def main() -> int:
|
|
|
310
317
|
# with neither names NO item, rather than putting a command name where four
|
|
311
318
|
# other slices expect an item.
|
|
312
319
|
item = rows[0]["name"] if rows else (pos[0] if pos else "")
|
|
313
|
-
committed, pushed, note = git.land(repo, item, rows)
|
|
320
|
+
committed, pushed, note = git.land(repo, item, rows, before)
|
|
314
321
|
if committed:
|
|
315
322
|
where = f" and pushed to {git.GIT['remote']}" if pushed else ""
|
|
316
323
|
sys.stdout.flush()
|
|
@@ -322,7 +329,7 @@ def _writes(cmd: str, flags: dict) -> bool:
|
|
|
322
329
|
"""Does THIS invocation change the board?
|
|
323
330
|
|
|
324
331
|
The command name answers it for all but one. `verify` writes the result of a run
|
|
325
|
-
it EXECUTES, and two of its four doors execute nothing: `--
|
|
332
|
+
it EXECUTES, and two of its four doors execute nothing: `--start` starts a
|
|
326
333
|
detached child that commits its own result, and `--status` only reports. Both
|
|
327
334
|
write to `work/.verify`, which is gitignored, so neither has anything of its own
|
|
328
335
|
to land — and both were driving a commit that swept up whatever the person had
|
|
@@ -332,7 +339,7 @@ def _writes(cmd: str, flags: dict) -> bool:
|
|
|
332
339
|
"""
|
|
333
340
|
if cmd not in git.WRITES:
|
|
334
341
|
return False
|
|
335
|
-
if cmd == "verify" and (flags.get("
|
|
342
|
+
if cmd == "verify" and (flags.get("start") or flags.get("status")):
|
|
336
343
|
return False
|
|
337
344
|
return True
|
|
338
345
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.121",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -63,10 +63,10 @@
|
|
|
63
63
|
"@jarvis/errors": "1.0.0",
|
|
64
64
|
"@jarvis/logger": "1.0.0",
|
|
65
65
|
"@jarvis/rpc": "1.0.0",
|
|
66
|
-
"@jarvis/types": "1.0.0",
|
|
67
66
|
"@jarvis/typescript-config": "1.0.0",
|
|
68
67
|
"@jarvis/ui": "0.1.0",
|
|
69
|
-
"@jarvis/vitest-config": "1.0.0"
|
|
68
|
+
"@jarvis/vitest-config": "1.0.0",
|
|
69
|
+
"@jarvis/types": "1.0.0"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"dev": "tsx watch src/bin.ts start --foreground",
|