@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/test_work.py
CHANGED
|
@@ -2807,60 +2807,162 @@ def test_a_tier_floor_is_derived_from_the_owner_and_cannot_be_lowered():
|
|
|
2807
2807
|
pass
|
|
2808
2808
|
|
|
2809
2809
|
|
|
2810
|
+
def _next_as(who, **args):
|
|
2811
|
+
"""Run `next` as one instance, and leave nothing of that identity behind."""
|
|
2812
|
+
os.environ["WORK_INSTANCE"] = who
|
|
2813
|
+
try:
|
|
2814
|
+
shift.cmd_next(args)
|
|
2815
|
+
finally:
|
|
2816
|
+
os.environ.pop("WORK_INSTANCE", None)
|
|
2817
|
+
peers._RUNNING.clear()
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
def _taken_by(root) -> dict:
|
|
2821
|
+
"""Who took what, read the way every reader of the board now reads it."""
|
|
2822
|
+
from harness import holders
|
|
2823
|
+
return {h["session"]: name
|
|
2824
|
+
for name, found in holders.to_holders_by_item(events.read(root)).items()
|
|
2825
|
+
for h in found}
|
|
2826
|
+
|
|
2827
|
+
|
|
2810
2828
|
def test_next_takes_one_task_and_a_second_instance_never_gets_the_same_one():
|
|
2811
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
2829
|
+
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as reg:
|
|
2812
2830
|
v = _tree(tmp)
|
|
2813
2831
|
e = _epic(v, "an-epic")
|
|
2814
2832
|
_task(e / "queue", "alpha", fm="priority: P0\ntier: 1\ncode: [web]\n")
|
|
2815
2833
|
_task(e / "queue", "beta", fm="priority: P0\ntier: 1\ncode: [tools]\n")
|
|
2816
|
-
|
|
2817
|
-
os.environ["
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2834
|
+
try:
|
|
2835
|
+
os.environ["WORK_SESSIONS_DIR"] = reg
|
|
2836
|
+
with _work_dir(tmp) as root:
|
|
2837
|
+
_next_as("one")
|
|
2838
|
+
_next_as("two")
|
|
2839
|
+
taken = _taken_by(root)
|
|
2840
|
+
assert set(taken) == {"one", "two"}, taken
|
|
2841
|
+
assert taken["one"] != taken["two"], "two instances took the same task"
|
|
2842
|
+
finally:
|
|
2843
|
+
os.environ.pop("WORK_SESSIONS_DIR", None)
|
|
2822
2844
|
|
|
2823
|
-
held = {}
|
|
2824
|
-
for t in model.scan(root)["versions"][0].all_tasks():
|
|
2825
|
-
c = shift.read_claim(t.folder)
|
|
2826
|
-
if c:
|
|
2827
|
-
held[c["instance"]] = t.name
|
|
2828
|
-
assert set(held) == {"one", "two"}, held
|
|
2829
|
-
assert held["one"] != held["two"], "two instances took the same task"
|
|
2830
2845
|
|
|
2846
|
+
def test_the_loop_holds_back_from_an_area_somebody_may_still_be_working():
|
|
2847
|
+
# Two runs on one seam is how parallel work produces two answers to one design
|
|
2848
|
+
# question. There is no lease any more: who holds an area is read from the commits
|
|
2849
|
+
# that took its items, and an area is free once everyone holding it has ENDED —
|
|
2850
|
+
# which is a thing only a session the list could have named can be said to be.
|
|
2851
|
+
first = "11111111-1111-4111-8111-111111111111"
|
|
2852
|
+
second = "22222222-2222-4222-8222-222222222222"
|
|
2853
|
+
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as reg:
|
|
2854
|
+
v = _tree(tmp)
|
|
2855
|
+
e = _epic(v, "an-epic")
|
|
2856
|
+
_task(e / "queue", "alpha", fm="priority: P0\ntier: 1\ncode: [web]\n")
|
|
2857
|
+
_task(e / "queue", "beta", fm="priority: P1\ntier: 1\ncode: [web]\n")
|
|
2858
|
+
registry = Path(reg) / "1.json"
|
|
2859
|
+
try:
|
|
2860
|
+
os.environ["WORK_SESSIONS_DIR"] = reg
|
|
2861
|
+
with _work_dir(tmp) as root:
|
|
2862
|
+
registry.write_text(json.dumps(
|
|
2863
|
+
{"sessionId": first, "name": "repo-a1", "pid": os.getpid()}))
|
|
2864
|
+
_next_as(first)
|
|
2865
|
+
_next_as(second)
|
|
2866
|
+
assert _taken_by(root) == {first: "alpha"}, \
|
|
2867
|
+
"the second run took an area a running session holds"
|
|
2868
|
+
|
|
2869
|
+
# The first session ends. Nothing had to expire and nobody had to drop
|
|
2870
|
+
# anything: its area is free the moment it is no longer running.
|
|
2871
|
+
registry.write_text(json.dumps(
|
|
2872
|
+
{"sessionId": first, "name": "repo-a1", "pid": 2 ** 22}))
|
|
2873
|
+
_next_as(second)
|
|
2874
|
+
assert _taken_by(root) == {first: "alpha", second: "beta"}, \
|
|
2875
|
+
"an area stayed held after everyone holding it had ended"
|
|
2876
|
+
finally:
|
|
2877
|
+
os.environ.pop("WORK_SESSIONS_DIR", None)
|
|
2878
|
+
peers._RUNNING.clear()
|
|
2831
2879
|
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
#
|
|
2835
|
-
#
|
|
2836
|
-
|
|
2880
|
+
|
|
2881
|
+
def test_a_run_this_machine_cannot_see_holds_its_area_rather_than_freeing_it():
|
|
2882
|
+
# A shift that names itself is never on the session list, so its absence from it
|
|
2883
|
+
# proves nothing. Reading that as ended would hand its area to a second run while
|
|
2884
|
+
# it may still be working — and all this decides is the loop holding back, which
|
|
2885
|
+
# is never the expensive mistake.
|
|
2886
|
+
second = "22222222-2222-4222-8222-222222222222"
|
|
2887
|
+
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as reg:
|
|
2837
2888
|
v = _tree(tmp)
|
|
2838
2889
|
e = _epic(v, "an-epic")
|
|
2839
2890
|
_task(e / "queue", "alpha", fm="priority: P0\ntier: 1\ncode: [web]\n")
|
|
2840
2891
|
_task(e / "queue", "beta", fm="priority: P1\ntier: 1\ncode: [web]\n")
|
|
2841
|
-
|
|
2842
|
-
os.environ["
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2892
|
+
try:
|
|
2893
|
+
os.environ["WORK_SESSIONS_DIR"] = reg
|
|
2894
|
+
with _work_dir(tmp) as root:
|
|
2895
|
+
_next_as("nightly-shift")
|
|
2896
|
+
_next_as(second)
|
|
2897
|
+
assert _taken_by(root) == {"nightly-shift": "alpha"}, \
|
|
2898
|
+
"a run nothing here can see had its area taken from under it"
|
|
2899
|
+
finally:
|
|
2900
|
+
os.environ.pop("WORK_SESSIONS_DIR", None)
|
|
2901
|
+
peers._RUNNING.clear()
|
|
2849
2902
|
|
|
2850
2903
|
|
|
2851
|
-
def
|
|
2852
|
-
|
|
2904
|
+
def test_an_instance_resumes_its_own_take_rather_than_taking_more():
|
|
2905
|
+
# One instance, one task. What it resumes is the take recorded under the name it is
|
|
2906
|
+
# known by — so a name given as a flag has to be the name the take is recorded
|
|
2907
|
+
# under, or the run would read its own take as somebody else's and move on.
|
|
2908
|
+
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as reg:
|
|
2853
2909
|
v = _tree(tmp)
|
|
2854
2910
|
e = _epic(v, "an-epic")
|
|
2855
|
-
|
|
2856
|
-
(
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2911
|
+
_task(e / "queue", "alpha", fm="priority: P0\ntier: 1\ncode: [web]\n")
|
|
2912
|
+
_task(e / "queue", "beta", fm="priority: P1\ntier: 1\ncode: [tools]\n")
|
|
2913
|
+
try:
|
|
2914
|
+
os.environ["WORK_SESSIONS_DIR"] = reg
|
|
2915
|
+
with _work_dir(tmp) as root:
|
|
2916
|
+
for _ in range(2):
|
|
2917
|
+
try:
|
|
2918
|
+
shift.cmd_next({"instance": "nightly-shift"})
|
|
2919
|
+
finally:
|
|
2920
|
+
os.environ.pop("WORK_INSTANCE", None)
|
|
2921
|
+
assert _taken_by(root) == {"nightly-shift": "alpha"}, \
|
|
2922
|
+
"it took a second task instead of resuming its first"
|
|
2923
|
+
finally:
|
|
2924
|
+
os.environ.pop("WORK_SESSIONS_DIR", None)
|
|
2925
|
+
peers._RUNNING.clear()
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def test_status_names_who_took_each_item_from_the_commits_not_a_lease():
|
|
2929
|
+
# One answer to who is on an item. `status` read a lease first and the history only
|
|
2930
|
+
# when there was none, so the one screen a shift is read from and the board could
|
|
2931
|
+
# name different people. It reads the takes now, the way every other door does.
|
|
2932
|
+
import contextlib
|
|
2933
|
+
import io
|
|
2934
|
+
running = "11111111-1111-4111-8111-111111111111"
|
|
2935
|
+
gone = "33333333-3333-4333-8333-333333333333"
|
|
2936
|
+
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as reg:
|
|
2937
|
+
v = _tree(tmp)
|
|
2938
|
+
e = _epic(v, "an-epic")
|
|
2939
|
+
for name in ("alpha", "beta", "gamma"):
|
|
2940
|
+
_task(e / "in-progress", name, fm="priority: P1\ntier: 1\n")
|
|
2941
|
+
(Path(reg) / "1.json").write_text(json.dumps(
|
|
2942
|
+
{"sessionId": running, "name": "repo-a1", "pid": os.getpid()}))
|
|
2943
|
+
try:
|
|
2944
|
+
os.environ["WORK_SESSIONS_DIR"] = reg
|
|
2945
|
+
with _work_dir(tmp) as root:
|
|
2946
|
+
for who, name in ((running, "alpha"), (gone, "beta")):
|
|
2947
|
+
os.environ["WORK_INSTANCE"] = who
|
|
2948
|
+
events.append(root, "moved", name, **{"from": "queue", "to": "in-progress"})
|
|
2949
|
+
os.environ["WORK_INSTANCE"] = "44444444-4444-4444-8444-444444444444"
|
|
2950
|
+
out = io.StringIO()
|
|
2951
|
+
with contextlib.redirect_stdout(out):
|
|
2952
|
+
shift.cmd_status({})
|
|
2953
|
+
said = out.getvalue()
|
|
2954
|
+
# The same derivation answers the note a person gets before moving it.
|
|
2955
|
+
assert "repo-a1" in shift.somebody_else(root, "alpha")
|
|
2956
|
+
assert shift.somebody_else(root, "beta") == "", \
|
|
2957
|
+
"a take whose run has ended is not somebody still on it"
|
|
2958
|
+
assert "repo-a1" in said and "still running" in said, said
|
|
2959
|
+
assert "ENDED" in said, "a take whose run has ended has to say so"
|
|
2960
|
+
assert "never taken" in said, "and an item nobody took has to say that"
|
|
2961
|
+
assert "lease" not in said
|
|
2962
|
+
finally:
|
|
2963
|
+
for key in ("WORK_SESSIONS_DIR", "WORK_INSTANCE"):
|
|
2964
|
+
os.environ.pop(key, None)
|
|
2965
|
+
peers._RUNNING.clear()
|
|
2864
2966
|
|
|
2865
2967
|
|
|
2866
2968
|
def test_a_task_above_the_ceiling_is_never_taken_unattended():
|
|
@@ -3086,9 +3188,6 @@ def test_a_passing_verify_run_is_recorded_and_clears_that_gate():
|
|
|
3086
3188
|
# moment its last task lands, so the folder is already elsewhere.
|
|
3087
3189
|
done = model.locate(root, "alpha")
|
|
3088
3190
|
assert done.status == "complete"
|
|
3089
|
-
# The lease does not survive the work: `status` would otherwise
|
|
3090
|
-
# report a live instance on a finished task.
|
|
3091
|
-
assert not (done.folder / ".claim").exists()
|
|
3092
3191
|
finally:
|
|
3093
3192
|
gate.VERIFY = old
|
|
3094
3193
|
|
|
@@ -3712,8 +3811,7 @@ def test_a_failing_run_is_described_by_what_the_gate_said():
|
|
|
3712
3811
|
assert "abcdef12" in said
|
|
3713
3812
|
|
|
3714
3813
|
|
|
3715
|
-
def
|
|
3716
|
-
assert git.CLAIM in git.LOCAL
|
|
3814
|
+
def test_a_verify_run_never_enters_a_commit():
|
|
3717
3815
|
assert gate.RUN in git.LOCAL
|
|
3718
3816
|
|
|
3719
3817
|
|
|
@@ -3909,7 +4007,7 @@ def test_the_autonomy_ceiling_is_validated_and_never_silently_wrong():
|
|
|
3909
4007
|
for bad in (4, -1, "2", True, None):
|
|
3910
4008
|
try:
|
|
3911
4009
|
config._validate({**config.DEFAULTS,
|
|
3912
|
-
"autonomy": {"ceiling": bad
|
|
4010
|
+
"autonomy": {"ceiling": bad}})
|
|
3913
4011
|
assert False, f"expected a refusal for {bad!r}"
|
|
3914
4012
|
except config.ConfigError:
|
|
3915
4013
|
pass
|
|
@@ -4614,28 +4712,37 @@ def test_the_record_round_trips_out_of_the_commits():
|
|
|
4614
4712
|
config.apply(config.DEFAULTS)
|
|
4615
4713
|
|
|
4616
4714
|
|
|
4617
|
-
def
|
|
4618
|
-
#
|
|
4619
|
-
#
|
|
4620
|
-
#
|
|
4715
|
+
def test_migrate_sweeps_the_lease_files_the_deleted_lease_left_behind():
|
|
4716
|
+
# Nothing reads a `.claim` any more and git no longer filters one out of a commit,
|
|
4717
|
+
# so a leftover would ride into the next board write in its folder. The migration a
|
|
4718
|
+
# repo already runs for layout changes is where it goes — and the file it removes,
|
|
4719
|
+
# being untracked, must not be what stops it running.
|
|
4720
|
+
from harness import migrate
|
|
4621
4721
|
with tempfile.TemporaryDirectory() as tmp:
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
assert
|
|
4631
|
-
assert ".
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4722
|
+
v = _tree(tmp)
|
|
4723
|
+
e = _epic(v, "an-epic")
|
|
4724
|
+
held = _task(e / "in-progress", "alpha")
|
|
4725
|
+
(held / ".claim").write_text('{"instance": "someone"}\n')
|
|
4726
|
+
with _work_dir(tmp):
|
|
4727
|
+
migrate.cmd_migrate({"dry-run": True})
|
|
4728
|
+
assert (held / ".claim").exists(), "a dry run changes nothing"
|
|
4729
|
+
migrate.cmd_migrate({})
|
|
4730
|
+
assert not (held / ".claim").exists(), "the leftover was left to be committed"
|
|
4731
|
+
assert (held / "task.md").exists(), "and nothing but the leftover was removed"
|
|
4732
|
+
|
|
4733
|
+
# A repo still on its old backlog gets both at once — and the backlog moves first,
|
|
4734
|
+
# carrying any leftover inside it to a path the sweep never listed.
|
|
4735
|
+
import shutil
|
|
4736
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
4737
|
+
root = Path(tmp) / "work"
|
|
4738
|
+
_tree(str(root))
|
|
4739
|
+
shutil.rmtree(root / "versions" / "backlog")
|
|
4740
|
+
later = _task(root / "backlog" / "an-epic", "later")
|
|
4741
|
+
(later / ".claim").write_text('{"instance": "someone"}\n')
|
|
4742
|
+
with _work_dir(str(root)):
|
|
4743
|
+
migrate.cmd_migrate({})
|
|
4744
|
+
assert not (root / "backlog").exists(), "the old backlog did not move"
|
|
4745
|
+
assert not list(root.rglob(".claim")), "a leftover the backlog carried was not swept"
|
|
4639
4746
|
|
|
4640
4747
|
|
|
4641
4748
|
def test_a_commit_leaves_a_sessions_own_code_alone():
|
|
@@ -4965,23 +5072,28 @@ def test_a_held_task_names_a_session_you_can_actually_reach():
|
|
|
4965
5072
|
os.environ["WORK_SESSIONS_DIR"] = tmp
|
|
4966
5073
|
# A registry that exists and holds exactly one running session. The pid
|
|
4967
5074
|
# is this test's own, because the check is whether the process is there.
|
|
5075
|
+
# Shaped like the ids the registry publishes, because only an id of that
|
|
5076
|
+
# shape can be declared ended by its absence from it.
|
|
5077
|
+
theirs = "11111111-1111-4111-8111-111111111111"
|
|
5078
|
+
alive = "22222222-2222-4222-8222-222222222222"
|
|
5079
|
+
ghost = "33333333-3333-4333-8333-333333333333"
|
|
4968
5080
|
(Path(tmp) / "1.json").write_text(json.dumps(
|
|
4969
|
-
{"sessionId":
|
|
5081
|
+
{"sessionId": alive, "name": "repo-a8", "pid": os.getpid()}))
|
|
4970
5082
|
# …and one entry a hard kill left behind. It must not read as running.
|
|
4971
5083
|
(Path(tmp) / "2.json").write_text(json.dumps(
|
|
4972
|
-
{"sessionId":
|
|
5084
|
+
{"sessionId": ghost, "name": "repo-b7", "pid": 2 ** 22}))
|
|
4973
5085
|
|
|
4974
5086
|
assert "this session" in peers.describe("mine-0000")
|
|
4975
5087
|
|
|
4976
|
-
away = peers.describe(
|
|
5088
|
+
away = peers.describe(theirs, "other-box")
|
|
4977
5089
|
assert "another machine" in away and "SendMessage" not in away, \
|
|
4978
5090
|
"a name you cannot deliver to is worse than saying you cannot"
|
|
4979
5091
|
|
|
4980
|
-
live = peers.describe(
|
|
5092
|
+
live = peers.describe(alive, "this-box")
|
|
4981
5093
|
assert "repo-a8" in live and "SendMessage" in live, \
|
|
4982
5094
|
"a running peer is reachable — hand over the address, not a prefix"
|
|
4983
5095
|
|
|
4984
|
-
for gone in (
|
|
5096
|
+
for gone in (theirs, ghost):
|
|
4985
5097
|
ended = peers.describe(gone, "this-box")
|
|
4986
5098
|
assert "ENDED" in ended and "SendMessage" not in ended, \
|
|
4987
5099
|
"a session that is not running cannot be messaged — say so"
|
|
@@ -4990,7 +5102,7 @@ def test_a_held_task_names_a_session_you_can_actually_reach():
|
|
|
4990
5102
|
# that everyone is dead. It must fall back to what it always said.
|
|
4991
5103
|
os.environ["WORK_SESSIONS_DIR"] = str(Path(tmp) / "nothing-here")
|
|
4992
5104
|
assert peers.running() is None
|
|
4993
|
-
unknown = peers.describe(
|
|
5105
|
+
unknown = peers.describe(theirs, "this-box")
|
|
4994
5106
|
assert "SendMessage" in unknown and "ENDED" not in unknown, \
|
|
4995
5107
|
"a client that publishes nothing must not make every peer look dead"
|
|
4996
5108
|
finally:
|
|
@@ -5120,13 +5232,14 @@ def test_a_session_running_twice_is_never_resolved_to_one_of_its_names():
|
|
|
5120
5232
|
|
|
5121
5233
|
# The real pair, with the filenames from the sighting: the low pid sorts
|
|
5122
5234
|
# LAST lexically, so whichever half a reader picked was arbitrary.
|
|
5235
|
+
forked = "44444444-4444-4444-8444-444444444444"
|
|
5123
5236
|
for pid, name, door in ((20558, "jarvis-9a", "claude-vscode"),
|
|
5124
5237
|
(15750, "the harness stops", "cli")):
|
|
5125
5238
|
(reg / f"{pid}.json").write_text(json.dumps(
|
|
5126
|
-
{"sessionId":
|
|
5239
|
+
{"sessionId": forked, "name": name, "cwd": str(here),
|
|
5127
5240
|
"entrypoint": door, "pid": os.getpid()}))
|
|
5128
5241
|
|
|
5129
|
-
said = peers.describe(
|
|
5242
|
+
said = peers.describe(forked, "this-box")
|
|
5130
5243
|
assert "jarvis-9a" in said and "the harness stops" in said, \
|
|
5131
5244
|
"a reader that cannot pick has to be handed both, not one of them"
|
|
5132
5245
|
assert "2 processes on ONE session" in said
|
|
@@ -5143,15 +5256,46 @@ def test_a_session_running_twice_is_never_resolved_to_one_of_its_names():
|
|
|
5143
5256
|
# exactly as it did before any of this.
|
|
5144
5257
|
(reg / "20558.json").unlink()
|
|
5145
5258
|
peers._RUNNING.clear()
|
|
5146
|
-
alone = peers.describe(
|
|
5259
|
+
alone = peers.describe(forked, "this-box")
|
|
5147
5260
|
assert "still running" in alone and "the harness stops" in alone
|
|
5148
5261
|
assert "processes" not in alone
|
|
5262
|
+
|
|
5263
|
+
# A shift that names itself was never going to be on that list, so its
|
|
5264
|
+
# absence proves nothing — and the line must not promise an address a
|
|
5265
|
+
# named shift or a script cannot be messaged at.
|
|
5266
|
+
shift_line = peers.describe("nightly-shift", "this-box")
|
|
5267
|
+
assert "ENDED" not in shift_line and "SendMessage" not in shift_line
|
|
5268
|
+
assert "cannot be told" in shift_line
|
|
5149
5269
|
finally:
|
|
5150
5270
|
for key in ("WORK_INSTANCE", "WORK_MACHINE", "WORK_SESSIONS_DIR"):
|
|
5151
5271
|
os.environ.pop(key, None)
|
|
5152
5272
|
peers._RUNNING.clear()
|
|
5153
5273
|
|
|
5154
5274
|
|
|
5275
|
+
def test_both_readers_of_the_board_history_agree_about_who_holds_each_item():
|
|
5276
|
+
# Who is on an item is derived twice — here from what `git._events` parses, and by
|
|
5277
|
+
# the board from what `board.git.ts` reads — and the two had already come to name
|
|
5278
|
+
# different holders: a take on an item already in progress records a run and no
|
|
5279
|
+
# event, which the board counted and this reader threw away. The TypeScript half
|
|
5280
|
+
# asserting these same commits is in packages/board/src/board.test.ts.
|
|
5281
|
+
from harness import holders
|
|
5282
|
+
cases = json.loads((Path(__file__).parent / "harness" / "holders.cases.json")
|
|
5283
|
+
.read_text(encoding="utf-8"))["cases"]
|
|
5284
|
+
assert cases, "the shared cases file has to actually hold cases"
|
|
5285
|
+
for case in cases:
|
|
5286
|
+
rows = []
|
|
5287
|
+
for commit in case["commits"]:
|
|
5288
|
+
record = "\x1f".join((commit["sha"], commit["when"], commit["author"],
|
|
5289
|
+
commit["message"]))
|
|
5290
|
+
rows.extend(git._events(record, eventless=True))
|
|
5291
|
+
# An item nobody took reads as having no holders, however a reader represents
|
|
5292
|
+
# that — so an empty list and an absent key compare the same.
|
|
5293
|
+
got = {name: [{"session": h["session"], "machine": h["machine"],
|
|
5294
|
+
"tookOverFrom": h.get("took_over_from")} for h in found]
|
|
5295
|
+
for name, found in holders.to_holders_by_item(rows).items() if found}
|
|
5296
|
+
assert got == case["expect"], case["name"]
|
|
5297
|
+
|
|
5298
|
+
|
|
5155
5299
|
def test_a_session_starts_knowing_who_else_is_in_the_checkout():
|
|
5156
5300
|
# A session started blind: the one block it cannot skip said nothing about
|
|
5157
5301
|
# another session, so two runs picked up overlapping work and found out when one
|
|
@@ -5223,21 +5367,6 @@ def test_how_long_ago_is_said_in_the_unit_that_changes_the_decision():
|
|
|
5223
5367
|
assert shift._ago(broken) == "at an unrecorded time"
|
|
5224
5368
|
|
|
5225
5369
|
|
|
5226
|
-
def test_a_claim_records_which_machine_took_it():
|
|
5227
|
-
# A claim never enters git, so the pairing of session-to-machine exists nowhere
|
|
5228
|
-
# else. Without it every hold reads as local and half of them are lies.
|
|
5229
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
5230
|
-
os.environ["WORK_MACHINE"] = "the-box-that-took-it"
|
|
5231
|
-
try:
|
|
5232
|
-
folder = Path(tmp)
|
|
5233
|
-
claim = shift._write_claim(folder, "some-session")
|
|
5234
|
-
assert claim["machine"] == "the-box-that-took-it"
|
|
5235
|
-
assert json.loads((folder / ".claim").read_text())["machine"] \
|
|
5236
|
-
== "the-box-that-took-it"
|
|
5237
|
-
finally:
|
|
5238
|
-
os.environ.pop("WORK_MACHINE", None)
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
5370
|
def test_the_board_moving_under_you_says_what_moved_and_who_moved_it():
|
|
5242
5371
|
# The question at this moment is not "did a fetch happen" — a session can see
|
|
5243
5372
|
# that. It is whether the plan it is holding is still the plan, and who to ask.
|
|
@@ -5246,11 +5375,14 @@ def test_the_board_moving_under_you_says_what_moved_and_who_moved_it():
|
|
|
5246
5375
|
os.environ["WORK_INSTANCE"] = "mine-0000"
|
|
5247
5376
|
os.environ["WORK_MACHINE"] = "this-box"
|
|
5248
5377
|
os.environ["WORK_SESSIONS_DIR"] = tmp
|
|
5378
|
+
# Shaped like the ids the registry publishes, because only an id of that
|
|
5379
|
+
# shape can be declared ended by its absence from it.
|
|
5380
|
+
theirs = "11111111-1111-4111-8111-111111111111"
|
|
5249
5381
|
(Path(tmp) / "1.json").write_text(json.dumps(
|
|
5250
|
-
{"sessionId":
|
|
5382
|
+
{"sessionId": theirs, "name": "repo-c4", "pid": os.getpid()}))
|
|
5251
5383
|
note = peers.arrivals([
|
|
5252
|
-
{"name": "the-plan-changed", "by":
|
|
5253
|
-
{"name": "and-this-one", "by":
|
|
5384
|
+
{"name": "the-plan-changed", "by": theirs, "machine": "this-box"},
|
|
5385
|
+
{"name": "and-this-one", "by": theirs, "machine": "this-box"},
|
|
5254
5386
|
])
|
|
5255
5387
|
assert "the-plan-changed" in note and "and-this-one" in note
|
|
5256
5388
|
assert "SendMessage" in note, "naming who moved it is the point of saying it"
|
|
@@ -5258,9 +5390,11 @@ def test_the_board_moving_under_you_says_what_moved_and_who_moved_it():
|
|
|
5258
5390
|
# The same board move, made by somebody who has since finished. Still
|
|
5259
5391
|
# worth reporting — the plan did change under you — but there is nobody
|
|
5260
5392
|
# left to ask about it, and saying otherwise sends the reader nowhere.
|
|
5393
|
+
left = "99999999-9999-4999-8999-999999999999"
|
|
5261
5394
|
gone = peers.arrivals([
|
|
5262
|
-
{"name": "the-plan-changed", "by":
|
|
5395
|
+
{"name": "the-plan-changed", "by": left, "machine": "this-box"}])
|
|
5263
5396
|
assert "the-plan-changed" in gone and "SendMessage" not in gone
|
|
5397
|
+
assert "ENDED" in gone, "finished is a claim the list can make about a session"
|
|
5264
5398
|
# Your OWN commits coming back round are not news, and a line that fires
|
|
5265
5399
|
# on every pull is one people stop reading.
|
|
5266
5400
|
assert peers.arrivals([{"name": "x", "by": "mine-0000",
|
|
@@ -6506,7 +6640,7 @@ def test_every_shipped_default_is_mechanics_or_nothing():
|
|
|
6506
6640
|
# fact about somebody's setup and ships as null.
|
|
6507
6641
|
allowed = {
|
|
6508
6642
|
"ids.prefix", "domains.order", "product.tiers",
|
|
6509
|
-
"autonomy.ceiling", "
|
|
6643
|
+
"autonomy.ceiling", "git.remote", "git.paths",
|
|
6510
6644
|
"coverage.shard", "hooks.session_start.enabled", "hooks.stop.enabled",
|
|
6511
6645
|
# On by default like its two siblings, and for their reason: a hook the
|
|
6512
6646
|
# harness ships is its own mechanics, not a fact about somebody's setup.
|
|
@@ -7566,19 +7700,6 @@ def test_every_event_the_harness_records_is_one_the_record_knows():
|
|
|
7566
7700
|
f"recorded but not in KINDS, so it never reaches a digest: {sorted(called - set(events.KINDS))}"
|
|
7567
7701
|
|
|
7568
7702
|
|
|
7569
|
-
def test_a_claim_nobody_can_read_reads_as_absent_rather_than_raising():
|
|
7570
|
-
# `read_claim` promises an unreadable claim reads as absent, and the comparison
|
|
7571
|
-
# threw rather than the parse: a `.claim` written without a timezone parsed
|
|
7572
|
-
# fine and then could not be compared against an aware now. One malformed file
|
|
7573
|
-
# took down every command that asks who is holding an item.
|
|
7574
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
7575
|
-
folder = Path(tmp) / "a-task"
|
|
7576
|
-
folder.mkdir()
|
|
7577
|
-
(folder / shift.CLAIM).write_text(json.dumps(
|
|
7578
|
-
{"instance": "someone", "expires": "2099-01-01T00:00:00"}))
|
|
7579
|
-
assert shift.read_claim(folder) is None
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
7703
|
def test_the_lowest_tier_the_vocabulary_defines_can_actually_be_set():
|
|
7583
7704
|
# `TIERS` and `TIER_MEANING` both define tier 0 and the method's own table
|
|
7584
7705
|
# documents it — reversible and local, a typo, a comment, a test name — and it
|
package/harness/work.py
CHANGED
|
@@ -100,7 +100,7 @@ Subcommands:
|
|
|
100
100
|
The unattended loop — a schedule calls `next`, and the rest exists so a run that
|
|
101
101
|
cannot finish has somewhere to put the reason instead of stalling or guessing:
|
|
102
102
|
|
|
103
|
-
next [--instance <id>] [--peek] take ONE task
|
|
103
|
+
next [--instance <id>] [--peek] take ONE task; prints the read order
|
|
104
104
|
status what shipped · what waits on you · what is at risk
|
|
105
105
|
peers who else is working this checkout, and on what.
|
|
106
106
|
The session block says it at startup; this asks
|
|
@@ -119,7 +119,6 @@ cannot finish has somewhere to put the reason instead of stalling or guessing:
|
|
|
119
119
|
park a question, move on — exits 0, never blocks
|
|
120
120
|
needs every open question, oldest first
|
|
121
121
|
answer <task> --choose "…" resolve it and put the task back to work
|
|
122
|
-
drop <name> [--why "…"] give a claim back
|
|
123
122
|
log [--since DATE] [--task <n>] · digest [--since DATE]
|
|
124
123
|
sync pull the board fresh; send anything unpushed
|
|
125
124
|
|
|
@@ -172,7 +171,7 @@ from harness.config import (DEFAULTS, ConfigError, apply, cmd_applies, cmd_confi
|
|
|
172
171
|
from harness.safety import cmd_doctor, cmd_id_new
|
|
173
172
|
from harness.autonomy import cmd_answer, cmd_ask, cmd_digest, cmd_log, cmd_needs
|
|
174
173
|
from harness.peers import cmd_peers
|
|
175
|
-
from harness.shift import
|
|
174
|
+
from harness.shift import cmd_next, cmd_status
|
|
176
175
|
from harness.gate import cmd_observed, cmd_verify
|
|
177
176
|
from harness.kickoff import cmd_kickoff
|
|
178
177
|
from harness.git import cmd_sync
|
|
@@ -189,7 +188,7 @@ SUBCOMMANDS = (
|
|
|
189
188
|
"epic-new", "feature-new", "version-new", "place", "handoff", "release", "archive",
|
|
190
189
|
"find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
|
|
191
190
|
"domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
|
|
192
|
-
"migrate", "next", "status", "
|
|
191
|
+
"migrate", "next", "status", "ask", "answer", "needs", "verify",
|
|
193
192
|
"observed", "log", "digest", "sync", "check", "links", "peers",
|
|
194
193
|
)
|
|
195
194
|
|
|
@@ -568,10 +567,6 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
568
567
|
return cmd_status(flags)
|
|
569
568
|
if cmd == "peers":
|
|
570
569
|
return cmd_peers(flags)
|
|
571
|
-
if cmd == "drop":
|
|
572
|
-
if not pos:
|
|
573
|
-
die("usage: jarvis work drop <name> [--why \"…\"]")
|
|
574
|
-
return cmd_drop({"name": pos[0], **flags})
|
|
575
570
|
if cmd == "ask":
|
|
576
571
|
if not pos:
|
|
577
572
|
die("usage: jarvis work ask <task> --question \"…\" "
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.131",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -60,11 +60,11 @@
|
|
|
60
60
|
"@jarvis/anthropic": "1.0.0",
|
|
61
61
|
"@jarvis/board": "0.1.0",
|
|
62
62
|
"@jarvis/data": "0.1.0",
|
|
63
|
-
"@jarvis/errors": "1.0.0",
|
|
64
63
|
"@jarvis/logger": "1.0.0",
|
|
64
|
+
"@jarvis/errors": "1.0.0",
|
|
65
65
|
"@jarvis/rpc": "1.0.0",
|
|
66
|
-
"@jarvis/types": "1.0.0",
|
|
67
66
|
"@jarvis/typescript-config": "1.0.0",
|
|
67
|
+
"@jarvis/types": "1.0.0",
|
|
68
68
|
"@jarvis/ui": "0.1.0",
|
|
69
69
|
"@jarvis/vitest-config": "1.0.0"
|
|
70
70
|
},
|