@appchy/jarvis 0.1.122 → 0.1.124
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 +164 -55
- package/dist/bin.js.map +1 -1
- package/harness/harness/config.py +30 -0
- package/harness/harness/git.py +9 -0
- package/harness/harness/kickoff.py +12 -2
- package/harness/harness/peers.cases.json +141 -0
- package/harness/harness/peers.py +189 -20
- package/harness/presets/appchy/PRESET.md +36 -0
- package/harness/test_work.py +159 -0
- package/harness/work.py +8 -2
- package/package.json +1 -1
package/harness/test_work.py
CHANGED
|
@@ -3566,6 +3566,32 @@ def test_a_slot_the_harness_cannot_write_is_not_reported_as_somebody_elses_run()
|
|
|
3566
3566
|
gate.VERIFY = old
|
|
3567
3567
|
|
|
3568
3568
|
|
|
3569
|
+
def test_a_session_is_named_for_the_work_not_for_its_title():
|
|
3570
|
+
# The founder could see only prompts: a picker shows each session's FIRST prompt,
|
|
3571
|
+
# which for a jarvis-started run is the whole kickoff block, so four sessions read
|
|
3572
|
+
# as four walls of near-identical text. The name field existed and carried the
|
|
3573
|
+
# TITLE — a 90-character sentence that truncates to the half saying nothing, and
|
|
3574
|
+
# that nobody can type. It is also what `ListAgents` lists and `SendMessage`
|
|
3575
|
+
# addresses, so it has to be a word a person can say out loud.
|
|
3576
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
3577
|
+
v = _tree(tmp)
|
|
3578
|
+
e = _epic(v, "an-epic")
|
|
3579
|
+
_task(e / "in-progress", "a-verify-says-whether-you-are-queued",
|
|
3580
|
+
body="# The verify lock refuses cleanly but cannot say you are queued, "
|
|
3581
|
+
"or that you were displaced\n")
|
|
3582
|
+
with _work_dir(tmp) as root:
|
|
3583
|
+
task = model.locate(root, "a-verify-says-whether-you-are-queued")
|
|
3584
|
+
assert kickoff.session_name(task) == "a-verify-says-whether-you-are-queued"
|
|
3585
|
+
# The title is what it must NOT be, and the title is the long one.
|
|
3586
|
+
assert len(task.title) > 60, task.title
|
|
3587
|
+
assert task.title not in kickoff.session_name(task)
|
|
3588
|
+
|
|
3589
|
+
# A second run on the same item leads with the number, because that is the
|
|
3590
|
+
# column a picker never truncates.
|
|
3591
|
+
task.sessions.append("2026-09-12 aaaa")
|
|
3592
|
+
assert kickoff.session_name(task) == "2 · a-verify-says-whether-you-are-queued"
|
|
3593
|
+
|
|
3594
|
+
|
|
3569
3595
|
def test_a_pid_somebody_else_owns_is_alive_not_dead():
|
|
3570
3596
|
# Two readings of one pid: `peers._live` treats a kernel refusal as alive and
|
|
3571
3597
|
# this treated it as dead, so a run owned by another user read as over and a
|
|
@@ -5041,6 +5067,139 @@ def test_a_refusal_about_a_shared_tree_names_who_else_is_in_it():
|
|
|
5041
5067
|
peers._RUNNING.clear()
|
|
5042
5068
|
|
|
5043
5069
|
|
|
5070
|
+
def test_both_readers_of_the_session_registry_agree_about_every_case():
|
|
5071
|
+
# One directory, two readers, two languages — the harness here and the board's
|
|
5072
|
+
# TypeScript, which reads it directly rather than paying a subprocess on every
|
|
5073
|
+
# board list. That is exactly where one session comes to be described two ways,
|
|
5074
|
+
# so the cases live in a file both suites load and both assert the same answer
|
|
5075
|
+
# from. The TypeScript half of this is in packages/board/src/board.test.ts; a
|
|
5076
|
+
# drift between them goes red in both rather than being reconciled by hand.
|
|
5077
|
+
cases = json.loads((Path(__file__).parent / "harness" / "peers.cases.json")
|
|
5078
|
+
.read_text(encoding="utf-8"))["cases"]
|
|
5079
|
+
assert cases, "the shared cases file has to actually hold cases"
|
|
5080
|
+
for case in cases:
|
|
5081
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5082
|
+
try:
|
|
5083
|
+
reg = Path(tmp) / "sessions"
|
|
5084
|
+
if case["sessions"] is not None:
|
|
5085
|
+
reg.mkdir()
|
|
5086
|
+
for entry in case["sessions"]:
|
|
5087
|
+
if "raw" in entry:
|
|
5088
|
+
(reg / entry["file"]).write_text(entry["raw"])
|
|
5089
|
+
continue
|
|
5090
|
+
body = {k: v for k, v in entry.items() if k != "file"}
|
|
5091
|
+
# A pid is a marker rather than a number: only the running
|
|
5092
|
+
# process is a pid a test can be sure is alive.
|
|
5093
|
+
body["pid"] = os.getpid() if body["pid"] == "self" else 2 ** 22
|
|
5094
|
+
(reg / entry["file"]).write_text(json.dumps(body))
|
|
5095
|
+
os.environ["WORK_SESSIONS_DIR"] = str(reg)
|
|
5096
|
+
peers._RUNNING.clear()
|
|
5097
|
+
assert peers.reach(case["ask"]) == case["expect"], case["name"]
|
|
5098
|
+
finally:
|
|
5099
|
+
os.environ.pop("WORK_SESSIONS_DIR", None)
|
|
5100
|
+
peers._RUNNING.clear()
|
|
5101
|
+
|
|
5102
|
+
|
|
5103
|
+
def test_a_session_running_twice_is_never_resolved_to_one_of_its_names():
|
|
5104
|
+
# Opening a jarvis-started session in a second local client starts a SECOND
|
|
5105
|
+
# process on the same id rather than attaching to the first, so both are alive
|
|
5106
|
+
# and both append to one transcript. This used to key one entry per id while
|
|
5107
|
+
# walking sorted filenames, so the later name silently won and which one that
|
|
5108
|
+
# was depended on how two pids happened to sort. Measured on a real machine,
|
|
5109
|
+
# five of seventeen live ids were doubled and the winner flipped pair to pair.
|
|
5110
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5111
|
+
try:
|
|
5112
|
+
here = Path(tmp) / "repo"
|
|
5113
|
+
here.mkdir()
|
|
5114
|
+
reg = Path(tmp) / "sessions"
|
|
5115
|
+
reg.mkdir()
|
|
5116
|
+
os.environ["WORK_INSTANCE"] = "mine-0000"
|
|
5117
|
+
os.environ["WORK_MACHINE"] = "this-box"
|
|
5118
|
+
os.environ["WORK_SESSIONS_DIR"] = str(reg)
|
|
5119
|
+
peers._RUNNING.clear()
|
|
5120
|
+
|
|
5121
|
+
# The real pair, with the filenames from the sighting: the low pid sorts
|
|
5122
|
+
# LAST lexically, so whichever half a reader picked was arbitrary.
|
|
5123
|
+
for pid, name, door in ((20558, "jarvis-9a", "claude-vscode"),
|
|
5124
|
+
(15750, "the harness stops", "cli")):
|
|
5125
|
+
(reg / f"{pid}.json").write_text(json.dumps(
|
|
5126
|
+
{"sessionId": "forked-4444", "name": name, "cwd": str(here),
|
|
5127
|
+
"entrypoint": door, "pid": os.getpid()}))
|
|
5128
|
+
|
|
5129
|
+
said = peers.describe("forked-4444", "this-box")
|
|
5130
|
+
assert "jarvis-9a" in said and "the harness stops" in said, \
|
|
5131
|
+
"a reader that cannot pick has to be handed both, not one of them"
|
|
5132
|
+
assert "2 processes on ONE session" in said
|
|
5133
|
+
assert "an editor" in said and "a terminal" in said, \
|
|
5134
|
+
"which half is which is published on disk — it costs nothing to say"
|
|
5135
|
+
|
|
5136
|
+
# The checkout line counts SESSIONS, and says why it has more names.
|
|
5137
|
+
line = peers.sharing(here)
|
|
5138
|
+
assert "1 other session is" in line, "two processes are still one session"
|
|
5139
|
+
assert "jarvis-9a" in line and "the harness stops" in line
|
|
5140
|
+
assert "two processes sharing a transcript" in line
|
|
5141
|
+
|
|
5142
|
+
# And one process on an id is untouched — the ordinary case must read
|
|
5143
|
+
# exactly as it did before any of this.
|
|
5144
|
+
(reg / "20558.json").unlink()
|
|
5145
|
+
peers._RUNNING.clear()
|
|
5146
|
+
alone = peers.describe("forked-4444", "this-box")
|
|
5147
|
+
assert "still running" in alone and "the harness stops" in alone
|
|
5148
|
+
assert "processes" not in alone
|
|
5149
|
+
finally:
|
|
5150
|
+
for key in ("WORK_INSTANCE", "WORK_MACHINE", "WORK_SESSIONS_DIR"):
|
|
5151
|
+
os.environ.pop(key, None)
|
|
5152
|
+
peers._RUNNING.clear()
|
|
5153
|
+
|
|
5154
|
+
|
|
5155
|
+
def test_a_session_starts_knowing_who_else_is_in_the_checkout():
|
|
5156
|
+
# A session started blind: the one block it cannot skip said nothing about
|
|
5157
|
+
# another session, so two runs picked up overlapping work and found out when one
|
|
5158
|
+
# overwrote the other. It says who, what they are on, and how to reach them —
|
|
5159
|
+
# and says NOTHING when you are alone, because every session in every repo
|
|
5160
|
+
# running this harness reads it forever.
|
|
5161
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
5162
|
+
try:
|
|
5163
|
+
here = Path(tmp) / "repo"
|
|
5164
|
+
here.mkdir()
|
|
5165
|
+
reg = Path(tmp) / "sessions"
|
|
5166
|
+
reg.mkdir()
|
|
5167
|
+
os.environ["WORK_INSTANCE"] = "mine-0000"
|
|
5168
|
+
os.environ["WORK_MACHINE"] = "this-box"
|
|
5169
|
+
os.environ["WORK_SESSIONS_DIR"] = str(reg)
|
|
5170
|
+
peers._RUNNING.clear()
|
|
5171
|
+
|
|
5172
|
+
def publish(n, run, name, cwd, door="cli"):
|
|
5173
|
+
(reg / f"{n}.json").write_text(json.dumps(
|
|
5174
|
+
{"sessionId": run, "name": name, "cwd": str(cwd),
|
|
5175
|
+
"entrypoint": door, "pid": os.getpid()}))
|
|
5176
|
+
|
|
5177
|
+
publish(1, "mine-0000", "repo-me", here)
|
|
5178
|
+
assert peers.briefing(here, [], "main") == "", \
|
|
5179
|
+
"alone in the checkout is silence, not a line saying so"
|
|
5180
|
+
|
|
5181
|
+
publish(2, "peer-1111", "repo-a8", here)
|
|
5182
|
+
peers._RUNNING.clear()
|
|
5183
|
+
rows = [{"by": "peer-1111", "name": "make-the-thing", "event": "moved"}]
|
|
5184
|
+
said = peers.briefing(here, rows, "main")
|
|
5185
|
+
assert "repo-a8" in said, "who"
|
|
5186
|
+
assert "make-the-thing" in said, "on what"
|
|
5187
|
+
assert "branch main" in said, "on which branch"
|
|
5188
|
+
assert "SendMessage" in said, "and how to reach them"
|
|
5189
|
+
assert "repo-me" not in said, "never about yourself"
|
|
5190
|
+
assert "jarvis work peers" in said, "and a way to ask again"
|
|
5191
|
+
|
|
5192
|
+
# A peer holding nothing is still a peer worth knowing about — it is the
|
|
5193
|
+
# session about to take the work you were going to.
|
|
5194
|
+
peers._RUNNING.clear()
|
|
5195
|
+
nothing = peers.briefing(here, [], "main")
|
|
5196
|
+
assert "repo-a8" in nothing and "holding nothing" in nothing
|
|
5197
|
+
finally:
|
|
5198
|
+
for key in ("WORK_INSTANCE", "WORK_MACHINE", "WORK_SESSIONS_DIR"):
|
|
5199
|
+
os.environ.pop(key, None)
|
|
5200
|
+
peers._RUNNING.clear()
|
|
5201
|
+
|
|
5202
|
+
|
|
5044
5203
|
def test_how_long_ago_is_said_in_the_unit_that_changes_the_decision():
|
|
5045
5204
|
# The question this answers is *is anybody on this*, and the reader is deciding
|
|
5046
5205
|
# whether to take the work. "6h ago" settles that; a timestamp makes them do
|
package/harness/work.py
CHANGED
|
@@ -89,6 +89,9 @@ cannot finish has somewhere to put the reason instead of stalling or guessing:
|
|
|
89
89
|
|
|
90
90
|
next [--instance <id>] [--peek] take ONE task and claim it; prints the read order
|
|
91
91
|
status what shipped · what waits on you · what is at risk
|
|
92
|
+
peers who else is working this checkout, and on what.
|
|
93
|
+
The session block says it at startup; this asks
|
|
94
|
+
again, because the answer is stale as it prints
|
|
92
95
|
method the METHOD in full — how work is done here. The session
|
|
93
96
|
block names it; this is the body, on request.
|
|
94
97
|
verify <name> [--start | --status] RUN verify.* (shell=False) and record the result.
|
|
@@ -155,6 +158,7 @@ from harness.config import (DEFAULTS, ConfigError, apply, cmd_applies, cmd_confi
|
|
|
155
158
|
cmd_context, cmd_method, cmd_remind, resolve)
|
|
156
159
|
from harness.safety import cmd_doctor, cmd_id_new
|
|
157
160
|
from harness.autonomy import cmd_answer, cmd_ask, cmd_digest, cmd_log, cmd_needs
|
|
161
|
+
from harness.peers import cmd_peers
|
|
158
162
|
from harness.shift import cmd_drop, cmd_next, cmd_status
|
|
159
163
|
from harness.gate import cmd_observed, cmd_verify
|
|
160
164
|
from harness.kickoff import cmd_kickoff
|
|
@@ -173,7 +177,7 @@ SUBCOMMANDS = (
|
|
|
173
177
|
"find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
|
|
174
178
|
"domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
|
|
175
179
|
"migrate", "next", "status", "drop", "ask", "answer", "needs", "verify",
|
|
176
|
-
"observed", "log", "digest", "sync", "check", "links",
|
|
180
|
+
"observed", "log", "digest", "sync", "check", "links", "peers",
|
|
177
181
|
)
|
|
178
182
|
|
|
179
183
|
|
|
@@ -366,7 +370,7 @@ def _say(note: str) -> None:
|
|
|
366
370
|
#: `--branch` could never have meant anything for it.
|
|
367
371
|
_READS = {"list", "find", "path", "code", "where", "rules", "status", "digest",
|
|
368
372
|
"log", "needs", "align", "coverage", "readme", "config", "context",
|
|
369
|
-
"doctor", "kickoff", "remind", "applies", "wrap"}
|
|
373
|
+
"doctor", "kickoff", "remind", "applies", "wrap", "peers"}
|
|
370
374
|
|
|
371
375
|
|
|
372
376
|
def _reads_only(cmd: str, flags: dict) -> None:
|
|
@@ -546,6 +550,8 @@ def dispatch(cmd, pos, flags, cfg) -> int:
|
|
|
546
550
|
return cmd_next(flags)
|
|
547
551
|
if cmd == "status":
|
|
548
552
|
return cmd_status(flags)
|
|
553
|
+
if cmd == "peers":
|
|
554
|
+
return cmd_peers(flags)
|
|
549
555
|
if cmd == "drop":
|
|
550
556
|
if not pos:
|
|
551
557
|
die("usage: jarvis work drop <name> [--why \"…\"]")
|