@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
|
@@ -868,6 +868,27 @@ def _what_this_repo_has_decided(cfg: dict, repo) -> list:
|
|
|
868
868
|
out.append("what the releases are for, so a plan does not win this week "
|
|
869
869
|
"and cost the next one:\n " + "\n ".join(cuts))
|
|
870
870
|
return out
|
|
871
|
+
def _who_else_is_here(repo) -> str:
|
|
872
|
+
"""The peers line, or `""` — composed by `peers`, which is the one place that
|
|
873
|
+
knows what a peer is, and assembled here only because this is where the board's
|
|
874
|
+
own history is already being read.
|
|
875
|
+
|
|
876
|
+
Everything it needs is best-effort: a repo that is not a git checkout has no
|
|
877
|
+
history and no branch, and the line degrades to who is live rather than failing.
|
|
878
|
+
A session start that cannot be told about its peers must still be told everything
|
|
879
|
+
else.
|
|
880
|
+
"""
|
|
881
|
+
if repo is None:
|
|
882
|
+
return ""
|
|
883
|
+
from . import events, git, peers
|
|
884
|
+
root = Path(repo) / "work"
|
|
885
|
+
try:
|
|
886
|
+
rows = events.read(root) if root.is_dir() else []
|
|
887
|
+
except (OSError, ValueError):
|
|
888
|
+
rows = []
|
|
889
|
+
return peers.briefing(repo, rows, git.branch(repo))
|
|
890
|
+
|
|
891
|
+
|
|
871
892
|
#: What the block is, said once, above every line of it. It names no repo and makes
|
|
872
893
|
#: no claim a config could contradict — which is what lets it be the same sentence
|
|
873
894
|
#: wherever the block is served.
|
|
@@ -893,6 +914,15 @@ def session_pointers(cfg: dict, repo=None) -> list:
|
|
|
893
914
|
out = [f"the board: tasks live under `work/` — `{cli()} list` shows what is "
|
|
894
915
|
f"moving, `{cli()} where <id>` resolves any rule id to its home."]
|
|
895
916
|
|
|
917
|
+
# Said FIRST of everything derived, and only when there is somebody: a session
|
|
918
|
+
# that picks up overlapping work finds out by overwriting it, and the moment to
|
|
919
|
+
# prevent that is before it has read anything else. Silence when alone is the
|
|
920
|
+
# design — this reaches every session in every repo running the harness, forever,
|
|
921
|
+
# so it has to cost nothing on the ordinary day.
|
|
922
|
+
peers_here = _who_else_is_here(repo)
|
|
923
|
+
if peers_here:
|
|
924
|
+
out.append(peers_here)
|
|
925
|
+
|
|
896
926
|
# The guidance stack, ANNOUNCED. What ships is an engine plus somebody's
|
|
897
927
|
# methodology, and a session that cannot tell which is which will cite the
|
|
898
928
|
# preset's reasoning as though the tool enforced it. Saying it also makes an
|
package/harness/harness/git.py
CHANGED
|
@@ -199,6 +199,15 @@ def machine() -> str:
|
|
|
199
199
|
return (os.environ.get("WORK_MACHINE") or socket.gethostname() or "").strip()
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
def branch(repo) -> str:
|
|
203
|
+
"""Which branch this checkout is on, or `""` — a detached HEAD and a directory
|
|
204
|
+
that is not a repo at all both answer the same way, because neither has a branch
|
|
205
|
+
to name and a caller only ever wants the name."""
|
|
206
|
+
code, out, _ = _git(repo, "rev-parse", "--abbrev-ref", "HEAD", timeout=5)
|
|
207
|
+
named = out.strip()
|
|
208
|
+
return "" if code != 0 or named == "HEAD" else named
|
|
209
|
+
|
|
210
|
+
|
|
202
211
|
def _git(repo, *argv, timeout=30):
|
|
203
212
|
"""Run one git command. Returns (code, stdout, stderr); a git that is missing or
|
|
204
213
|
hangs is a non-zero code with a reason, never an exception — a board write must
|
|
@@ -167,15 +167,25 @@ def session_name(task) -> str:
|
|
|
167
167
|
one Claude Code derives a name from the FOLDER — identical for every session
|
|
168
168
|
in a repo, so several runs are told apart only by an id nobody reads.
|
|
169
169
|
|
|
170
|
+
**It is the item's NAME, not its title** (founder, 2026-09-12). A title is a
|
|
171
|
+
sentence — "The verify lock refuses cleanly but cannot say you are queued, or
|
|
172
|
+
that you were displaced" is 90 characters — and a picker truncates it to the
|
|
173
|
+
half that carries no information, so four sessions read as four rows of
|
|
174
|
+
near-identical prose. Worse, it is not a string a person can type: this name
|
|
175
|
+
is also what `ListAgents` lists and `SendMessage` addresses, so it has to be
|
|
176
|
+
something somebody can say out loud to point an agent at one session. The
|
|
177
|
+
item's name is already that — short, unique across the whole board by
|
|
178
|
+
construction, and the same word the person would use for the work itself.
|
|
179
|
+
|
|
170
180
|
A run is numbered by how many have already worked the item, so the first is
|
|
171
|
-
just the
|
|
181
|
+
just the name and the fifth says it is the fifth. The number leads because
|
|
172
182
|
that is the column a picker never truncates, and the count comes off
|
|
173
183
|
`sessions:` rather than being carried in the prompt: the wrap has just
|
|
174
184
|
recorded the run doing the wrapping, so the successor is the next one along
|
|
175
185
|
and nothing has to remember what number it is.
|
|
176
186
|
"""
|
|
177
187
|
ordinal = len(task.sessions) + 1
|
|
178
|
-
return task.
|
|
188
|
+
return task.name if ordinal == 1 else f"{ordinal} · {task.name}"
|
|
179
189
|
|
|
180
190
|
|
|
181
191
|
def cmd_kickoff(args) -> int:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
{
|
|
2
|
+
"why": [
|
|
3
|
+
"One directory is read by two languages: the harness reads ~/.claude/sessions itself, and",
|
|
4
|
+
"the board's TypeScript reads it again rather than paying a subprocess on every board list.",
|
|
5
|
+
"Two readers of one directory is where one session comes to be described two ways, so both",
|
|
6
|
+
"run THESE cases and assert THIS answer. A drift between them is a red test in both suites",
|
|
7
|
+
"rather than two answers a person has to reconcile.",
|
|
8
|
+
"",
|
|
9
|
+
"A case's `sessions` is null when the directory does not exist at all, which is a different",
|
|
10
|
+
"answer from an empty directory and must stay different: no directory means this client does",
|
|
11
|
+
"not say, and that is where every agent publishing nothing sits.",
|
|
12
|
+
"",
|
|
13
|
+
"`pid` is a marker, never a number: `self` is the running test's own process, which is the",
|
|
14
|
+
"only pid a test can be sure is alive, and `dead` is one nothing can be occupying."
|
|
15
|
+
],
|
|
16
|
+
"cases": [
|
|
17
|
+
{
|
|
18
|
+
"name": "a run with one live process is reachable by the name it published",
|
|
19
|
+
"sessions": [
|
|
20
|
+
{ "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" }
|
|
21
|
+
],
|
|
22
|
+
"ask": "alive-2222",
|
|
23
|
+
"expect": {
|
|
24
|
+
"live": "running",
|
|
25
|
+
"processes": 1,
|
|
26
|
+
"addresses": [{ "name": "repo-a8", "from": "a terminal" }]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "a run whose process is gone is ended, not merely unlisted",
|
|
31
|
+
"sessions": [
|
|
32
|
+
{ "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" },
|
|
33
|
+
{ "file": "2.json", "sessionId": "ghost-3333", "name": "repo-b7", "pid": "dead", "entrypoint": "cli" }
|
|
34
|
+
],
|
|
35
|
+
"ask": "ghost-3333",
|
|
36
|
+
"expect": { "live": "ended", "processes": 0, "addresses": [] }
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "a run nothing published is ended, because something published a list without it",
|
|
40
|
+
"sessions": [
|
|
41
|
+
{ "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" }
|
|
42
|
+
],
|
|
43
|
+
"ask": "never-heard-of-it",
|
|
44
|
+
"expect": { "live": "ended", "processes": 0, "addresses": [] }
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "a take that named no run at all concludes nothing, because there is nothing to look up",
|
|
48
|
+
"sessions": [
|
|
49
|
+
{ "file": "1.json", "sessionId": "alive-2222", "name": "repo-a8", "pid": "self", "entrypoint": "cli" }
|
|
50
|
+
],
|
|
51
|
+
"ask": "",
|
|
52
|
+
"expect": { "live": "unknown", "processes": 0, "addresses": [] }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "a client that publishes no list makes every run unknown, never ended",
|
|
56
|
+
"sessions": null,
|
|
57
|
+
"ask": "alive-2222",
|
|
58
|
+
"expect": { "live": "unknown", "processes": 0, "addresses": [] }
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "an empty list is a claim, so a run not on it has ended",
|
|
62
|
+
"sessions": [],
|
|
63
|
+
"ask": "alive-2222",
|
|
64
|
+
"expect": { "live": "ended", "processes": 0, "addresses": [] }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "two live processes on one id are a fork, named both ways and neither picked",
|
|
68
|
+
"sessions": [
|
|
69
|
+
{ "file": "20558.json", "sessionId": "forked-4444", "name": "jarvis-9a", "pid": "self", "entrypoint": "claude-vscode" },
|
|
70
|
+
{ "file": "15750.json", "sessionId": "forked-4444", "name": "the harness stops", "pid": "self", "entrypoint": "cli" }
|
|
71
|
+
],
|
|
72
|
+
"ask": "forked-4444",
|
|
73
|
+
"expect": {
|
|
74
|
+
"live": "forked",
|
|
75
|
+
"processes": 2,
|
|
76
|
+
"addresses": [
|
|
77
|
+
{ "name": "jarvis-9a", "from": "an editor" },
|
|
78
|
+
{ "name": "the harness stops", "from": "a terminal" }
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "a fork is counted by its processes even when one of them published no name",
|
|
84
|
+
"sessions": [
|
|
85
|
+
{ "file": "1.json", "sessionId": "forked-5555", "name": "named-one", "pid": "self", "entrypoint": "cli" },
|
|
86
|
+
{ "file": "2.json", "sessionId": "forked-5555", "name": "", "pid": "self", "entrypoint": "claude-vscode" }
|
|
87
|
+
],
|
|
88
|
+
"ask": "forked-5555",
|
|
89
|
+
"expect": {
|
|
90
|
+
"live": "forked",
|
|
91
|
+
"processes": 2,
|
|
92
|
+
"addresses": [{ "name": "named-one", "from": "a terminal" }]
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "a live run that published no name is running, with nothing to address it by",
|
|
97
|
+
"sessions": [
|
|
98
|
+
{ "file": "1.json", "sessionId": "nameless-6666", "name": "", "pid": "self", "entrypoint": "cli" }
|
|
99
|
+
],
|
|
100
|
+
"ask": "nameless-6666",
|
|
101
|
+
"expect": { "live": "running", "processes": 1, "addresses": [] }
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "a door this does not know is passed through under the name its client gave it",
|
|
105
|
+
"sessions": [
|
|
106
|
+
{ "file": "1.json", "sessionId": "alive-7777", "name": "repo-c1", "pid": "self", "entrypoint": "some-future-client" }
|
|
107
|
+
],
|
|
108
|
+
"ask": "alive-7777",
|
|
109
|
+
"expect": {
|
|
110
|
+
"live": "running",
|
|
111
|
+
"processes": 1,
|
|
112
|
+
"addresses": [{ "name": "repo-c1", "from": "some-future-client" }]
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "a run that names no door at all is still reachable by name",
|
|
117
|
+
"sessions": [
|
|
118
|
+
{ "file": "1.json", "sessionId": "alive-8888", "name": "repo-d2", "pid": "self" }
|
|
119
|
+
],
|
|
120
|
+
"ask": "alive-8888",
|
|
121
|
+
"expect": {
|
|
122
|
+
"live": "running",
|
|
123
|
+
"processes": 1,
|
|
124
|
+
"addresses": [{ "name": "repo-d2", "from": "" }]
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"name": "a half-written file is skipped rather than read as a dead session",
|
|
129
|
+
"sessions": [
|
|
130
|
+
{ "file": "1.json", "sessionId": "alive-9999", "name": "repo-e3", "pid": "self", "entrypoint": "cli" },
|
|
131
|
+
{ "file": "2.json", "raw": "{\"sessionId\": \"tru" }
|
|
132
|
+
],
|
|
133
|
+
"ask": "alive-9999",
|
|
134
|
+
"expect": {
|
|
135
|
+
"live": "running",
|
|
136
|
+
"processes": 1,
|
|
137
|
+
"addresses": [{ "name": "repo-e3", "from": "a terminal" }]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
package/harness/harness/peers.py
CHANGED
|
@@ -29,6 +29,7 @@ import os
|
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
|
|
31
31
|
from . import git
|
|
32
|
+
from .tree import cli
|
|
32
33
|
|
|
33
34
|
#: How much of an instance id is worth showing. Eight hex characters is what every
|
|
34
35
|
#: surface here has always printed and what `ListAgents` matches on sight.
|
|
@@ -84,6 +85,12 @@ def alive(pid) -> bool:
|
|
|
84
85
|
SESSIONS = "WORK_SESSIONS_DIR"
|
|
85
86
|
DEFAULT_SESSIONS = "~/.claude/sessions"
|
|
86
87
|
|
|
88
|
+
#: What a client calls the door a run came through, in words a person reads. A door
|
|
89
|
+
#: this does not know is passed through under the client's own name, because a name
|
|
90
|
+
#: we have not met still beats a guess — and there must be nothing here a client is
|
|
91
|
+
#: REQUIRED to publish for the rest of the answer to hold.
|
|
92
|
+
DOORS = {"cli": "a terminal", "claude-vscode": "an editor"}
|
|
93
|
+
|
|
87
94
|
#: Answers already worked out this run, keyed by the directory they came from. Keyed
|
|
88
95
|
#: rather than a single slot because the tests point this at several directories in
|
|
89
96
|
#: one process, and a cache that ignored where it read from would answer for the
|
|
@@ -95,6 +102,14 @@ def _live():
|
|
|
95
102
|
"""Every agent session live on THIS machine, keyed by session id, as the client
|
|
96
103
|
published it — or `None` when nothing here publishes that at all.
|
|
97
104
|
|
|
105
|
+
The value is a LIST because one session id can be more than one live process.
|
|
106
|
+
Opening a running session in a second local client starts a second process on the
|
|
107
|
+
same id rather than attaching to the first, so both are alive and both append to
|
|
108
|
+
one transcript. Keying one entry per id silently dropped whichever file sorted
|
|
109
|
+
earlier, which made the name handed to a reader depend on how two pids happened to
|
|
110
|
+
sort — measured on this machine, five of seventeen live ids are doubled, and which
|
|
111
|
+
half won flipped from pair to pair.
|
|
112
|
+
|
|
98
113
|
The whole entry rather than one field of it, because two questions are asked of
|
|
99
114
|
this directory now: who is running, and who is running IN THIS CHECKOUT. They
|
|
100
115
|
want the same scan, the same pid check and the same cache, and reading the
|
|
@@ -139,22 +154,44 @@ def _live():
|
|
|
139
154
|
run = str(entry.get("sessionId", ""))
|
|
140
155
|
if not run or not alive(entry.get("pid")):
|
|
141
156
|
continue
|
|
142
|
-
out[
|
|
157
|
+
out.setdefault(run, []).append(entry)
|
|
143
158
|
_RUNNING[key] = out
|
|
144
159
|
return out
|
|
145
160
|
|
|
146
161
|
|
|
162
|
+
def addresses(entries) -> list:
|
|
163
|
+
"""What reaches each of a session's live processes: `(name, door)` pairs, sorted.
|
|
164
|
+
|
|
165
|
+
A process that publishes no name is left out rather than listed blank — naming it
|
|
166
|
+
would send a reader looking for something `SendMessage` will not take.
|
|
167
|
+
"""
|
|
168
|
+
out = []
|
|
169
|
+
for entry in entries:
|
|
170
|
+
name = str(entry.get("name", "")).strip()
|
|
171
|
+
if not name:
|
|
172
|
+
continue
|
|
173
|
+
door = str(entry.get("entrypoint", "")).strip()
|
|
174
|
+
out.append((name, DOORS.get(door, door)))
|
|
175
|
+
out.sort()
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
147
179
|
def running():
|
|
148
|
-
"""Every agent session live on THIS machine as `{session id: name}`.
|
|
180
|
+
"""Every agent session live on THIS machine as `{session id: [name, ...]}`.
|
|
149
181
|
|
|
150
182
|
`None` and `{}` stay as far apart here as they are in `_live`: no directory
|
|
151
183
|
means *this client does not say*, and an empty mapping means something
|
|
152
184
|
published a list this session was not on.
|
|
185
|
+
|
|
186
|
+
The names are a LIST for the same reason `_live` holds one: an id answering to two
|
|
187
|
+
names is a session running as two processes, and picking one of them is the defect
|
|
188
|
+
this shape exists to make impossible.
|
|
153
189
|
"""
|
|
154
190
|
live = _live()
|
|
155
191
|
if live is None:
|
|
156
192
|
return None
|
|
157
|
-
return {run:
|
|
193
|
+
return {run: [name for name, _ in addresses(entries)]
|
|
194
|
+
for run, entries in live.items()}
|
|
158
195
|
|
|
159
196
|
|
|
160
197
|
def _inside(where: str, root) -> bool:
|
|
@@ -195,24 +232,65 @@ def sharing(root) -> str:
|
|
|
195
232
|
if not live:
|
|
196
233
|
return ""
|
|
197
234
|
mine = me()
|
|
198
|
-
names = []
|
|
199
|
-
for run,
|
|
200
|
-
if run == mine
|
|
235
|
+
names, runs = [], 0
|
|
236
|
+
for run, entries in live.items():
|
|
237
|
+
if run == mine:
|
|
201
238
|
continue
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
239
|
+
found = addresses([e for e in entries
|
|
240
|
+
if _inside(str(e.get("cwd", "")), root)])
|
|
241
|
+
if not found:
|
|
242
|
+
continue
|
|
243
|
+
runs += 1
|
|
244
|
+
names.extend(name for name, _ in found)
|
|
207
245
|
names.sort()
|
|
208
246
|
if not names:
|
|
209
247
|
return ""
|
|
210
248
|
shown = ", ".join(f"`{n}`" for n in names)
|
|
211
|
-
one =
|
|
212
|
-
|
|
249
|
+
one = runs == 1
|
|
250
|
+
line = (f"{runs} other session{'' if one else 's'} "
|
|
213
251
|
f"{'is' if one else 'are'} live in this checkout — {shown}. "
|
|
214
252
|
f"`SendMessage` reaches {'it' if one else 'them'}; ask before you stash "
|
|
215
253
|
f"or commit anything you did not write.")
|
|
254
|
+
# More names than sessions means one of them is running as two processes, which
|
|
255
|
+
# is a thing a reader about to message somebody needs to know: either name
|
|
256
|
+
# arrives at the same conversation, so picking between them decides nothing.
|
|
257
|
+
if len(names) > runs:
|
|
258
|
+
line += (" More names than sessions: one of these is running as two "
|
|
259
|
+
"processes sharing a transcript, so either of its names reaches it.")
|
|
260
|
+
return line
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def reach(instance: str, host: str = "") -> dict:
|
|
264
|
+
"""Whether a run is live and what reaches it — `{"live": …, "addresses": […]}`.
|
|
265
|
+
|
|
266
|
+
The structured answer, which `describe` renders into a sentence and the board's
|
|
267
|
+
TypeScript computes for itself. Both are pinned to one set of cases, because two
|
|
268
|
+
readers of one directory in two languages is exactly where the same session comes
|
|
269
|
+
to be described two ways, and a drift between them should be a red test rather
|
|
270
|
+
than two answers a person has to reconcile.
|
|
271
|
+
|
|
272
|
+
Four states, and the difference between two of them is the whole point. `ended`
|
|
273
|
+
is a CLAIM — something published a list and this run was not on it. `unknown` is
|
|
274
|
+
the absence of a claim: nothing here publishes one, or the run is on a machine
|
|
275
|
+
this one cannot see into. Collapsing them reports every peer of every client that
|
|
276
|
+
publishes nothing as dead, which is worse than the silence it replaces.
|
|
277
|
+
"""
|
|
278
|
+
who = (instance or "").strip()
|
|
279
|
+
away = bool(host.strip()) and bool(here()) and host.strip() != here()
|
|
280
|
+
live = None if away else _live()
|
|
281
|
+
# A take that named no run at all is not a run that has ended — there is nothing to
|
|
282
|
+
# look up, so there is nothing to conclude. Reading the miss as `ended` would have
|
|
283
|
+
# every board write made without a session id report as abandoned work.
|
|
284
|
+
if live is None or not who:
|
|
285
|
+
return {"live": "unknown", "processes": 0, "addresses": []}
|
|
286
|
+
entries = live.get(who)
|
|
287
|
+
if not entries:
|
|
288
|
+
return {"live": "ended", "processes": 0, "addresses": []}
|
|
289
|
+
# Counted from the processes, never from the addresses: one of them publishing no
|
|
290
|
+
# name would otherwise make a fork report as though it were a single run.
|
|
291
|
+
return {"live": "forked" if len(entries) > 1 else "running",
|
|
292
|
+
"processes": len(entries),
|
|
293
|
+
"addresses": [{"name": n, "from": d} for n, d in addresses(entries)]}
|
|
216
294
|
|
|
217
295
|
|
|
218
296
|
def describe(instance: str, host: str = "") -> str:
|
|
@@ -239,19 +317,28 @@ def describe(instance: str, host: str = "") -> str:
|
|
|
239
317
|
if box and here() and box != here():
|
|
240
318
|
return (f"{short} on {box} — another machine, so it cannot be reached from "
|
|
241
319
|
f"here; the board is the only thing you share")
|
|
242
|
-
|
|
243
|
-
|
|
320
|
+
got = reach(who)
|
|
321
|
+
found = got["addresses"]
|
|
322
|
+
if got["live"] == "unknown":
|
|
244
323
|
return (f"{short} — this machine: `ListAgents` lists it, `SendMessage` reaches "
|
|
245
324
|
f"it if the plan needs to change")
|
|
246
|
-
if
|
|
325
|
+
if got["live"] == "ended":
|
|
247
326
|
return (f"{short} — this machine, but that session has ENDED: there is "
|
|
248
327
|
f"nobody to reach, so this is abandoned rather than held")
|
|
249
|
-
|
|
250
|
-
|
|
328
|
+
if got["live"] == "forked":
|
|
329
|
+
if not found:
|
|
330
|
+
return (f"{short} — this machine, running as more than one process on ONE "
|
|
331
|
+
f"session, none of which publishes a name to address it by")
|
|
332
|
+
shown = ", ".join(f"`{a['name']}`" + (f" ({a['from']})" if a["from"] else "")
|
|
333
|
+
for a in found)
|
|
334
|
+
return (f"{short} — this machine, running as {got['processes']} processes on "
|
|
335
|
+
f"ONE session: {shown}. They share a transcript, so either reaches it "
|
|
336
|
+
f"and neither is THE address")
|
|
337
|
+
if not found:
|
|
251
338
|
return (f"{short} — this machine and still running, though it publishes no "
|
|
252
339
|
f"name to address it by")
|
|
253
|
-
return (f"`{name}` ({short}) — this machine and still running:
|
|
254
|
-
f"reaches it if the plan needs to change")
|
|
340
|
+
return (f"`{found[0]['name']}` ({short}) — this machine and still running: "
|
|
341
|
+
f"`SendMessage` reaches it if the plan needs to change")
|
|
255
342
|
|
|
256
343
|
|
|
257
344
|
def of_claim(claim: dict) -> str:
|
|
@@ -260,6 +347,88 @@ def of_claim(claim: dict) -> str:
|
|
|
260
347
|
return describe(str(claim.get("instance", "")), str(claim.get("machine", "")))
|
|
261
348
|
|
|
262
349
|
|
|
350
|
+
def briefing(root, rows: list, branch: str = "") -> str:
|
|
351
|
+
"""Who else is working this checkout right now and what they are on, or `""`.
|
|
352
|
+
|
|
353
|
+
The line a session is handed before it does anything, and the answer a session
|
|
354
|
+
gets when it asks again. Silence when you are alone is the design rather than an
|
|
355
|
+
optimisation: every session in every repo running this harness reads this forever,
|
|
356
|
+
so it costs nothing on the ordinary day and earns its place on the day it matters.
|
|
357
|
+
|
|
358
|
+
`rows` is the board's own history, so what a peer holds comes from the commits
|
|
359
|
+
that took it rather than from a second store. It is passed in because the caller
|
|
360
|
+
already has it, and because reading it here would make this module import the one
|
|
361
|
+
that imports it.
|
|
362
|
+
|
|
363
|
+
Machine and branch are answered by construction and are not looked up per peer: a
|
|
364
|
+
session live in THIS checkout is on this machine and on whatever branch the
|
|
365
|
+
checkout is on. Saying so once is the whole of that answer.
|
|
366
|
+
"""
|
|
367
|
+
live = _live()
|
|
368
|
+
if not live:
|
|
369
|
+
return ""
|
|
370
|
+
mine = me()
|
|
371
|
+
# The last board write each session made, which is the item it is on. A later
|
|
372
|
+
# write by somebody else does not move it: two sessions can hold one item.
|
|
373
|
+
held = {}
|
|
374
|
+
for row in rows:
|
|
375
|
+
by, name = str(row.get("by", "")), str(row.get("name", ""))
|
|
376
|
+
if by and name:
|
|
377
|
+
held[by] = name
|
|
378
|
+
said = []
|
|
379
|
+
for run in sorted(live):
|
|
380
|
+
if run == mine:
|
|
381
|
+
continue
|
|
382
|
+
found = addresses([e for e in live[run]
|
|
383
|
+
if _inside(str(e.get("cwd", "")), root)])
|
|
384
|
+
if not found:
|
|
385
|
+
continue
|
|
386
|
+
shown = ", ".join(f"`{n}`" + (f" ({d})" if d else "") for n, d in found)
|
|
387
|
+
on = held.get(run)
|
|
388
|
+
note = " — TWO processes on one session, sharing a transcript" \
|
|
389
|
+
if len(found) > 1 else ""
|
|
390
|
+
said.append(f"{shown} · {'on ' + on if on else 'holding nothing on the board'}"
|
|
391
|
+
f"{note}")
|
|
392
|
+
if not said:
|
|
393
|
+
return ""
|
|
394
|
+
where = f" on branch {branch}" if branch else ""
|
|
395
|
+
return (f"somebody else is working this checkout{where}, on this machine: "
|
|
396
|
+
+ " · ".join(said)
|
|
397
|
+
+ f". `SendMessage` reaches them by name. This is true as it prints and "
|
|
398
|
+
f"not for long — `{cli()} peers` asks again. Before you take work that "
|
|
399
|
+
f"overlaps theirs, ask them.")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def cmd_peers(args=None) -> int:
|
|
403
|
+
"""Ask again who else is here. The session block says it once at startup; this is
|
|
404
|
+
the same answer, now.
|
|
405
|
+
|
|
406
|
+
It ALWAYS prints, where the startup line says nothing when you are alone — a hook
|
|
407
|
+
that stays quiet costs a session nothing, while a command that answers with an
|
|
408
|
+
empty screen reads as broken rather than as an answer.
|
|
409
|
+
"""
|
|
410
|
+
from . import events, git
|
|
411
|
+
from .tree import find_work_root
|
|
412
|
+
root = find_work_root()
|
|
413
|
+
repo = root.parent
|
|
414
|
+
try:
|
|
415
|
+
rows = events.read(root)
|
|
416
|
+
except (OSError, ValueError):
|
|
417
|
+
rows = []
|
|
418
|
+
said = briefing(repo, rows, git.branch(repo))
|
|
419
|
+
if said:
|
|
420
|
+
print(said)
|
|
421
|
+
return 0
|
|
422
|
+
live = _live()
|
|
423
|
+
# The two silences are different answers and only one of them is good news.
|
|
424
|
+
if live is None:
|
|
425
|
+
print("nothing on this machine publishes a session list, so who else is here "
|
|
426
|
+
"cannot be told from here — not that nobody is.")
|
|
427
|
+
else:
|
|
428
|
+
print("nobody else is working this checkout.")
|
|
429
|
+
return 0
|
|
430
|
+
|
|
431
|
+
|
|
263
432
|
def arrivals(rows: list) -> str:
|
|
264
433
|
"""What just landed on the board from somewhere else, as one line.
|
|
265
434
|
|
|
@@ -51,6 +51,11 @@ repo here has ONE server named `jarvis` and it is that repo's own — a checked-
|
|
|
51
51
|
|
|
52
52
|
**The loop, in order:**
|
|
53
53
|
|
|
54
|
+
0. **Who else is here.** The session block names them at startup and says nothing when you are
|
|
55
|
+
alone; `jarvis work peers` asks again, and it is the one answer in this loop that is stale the
|
|
56
|
+
moment it prints. Do it before you scope, because the cheapest collision to avoid is the one
|
|
57
|
+
where two sessions plan the same edit set — see §Somebody else is on it for what to do when the
|
|
58
|
+
answer is *somebody, on your regions*.
|
|
54
59
|
1. **`map_brief {}`** — orient. What is in flight, the most-cited rules, health, and **whether the
|
|
55
60
|
index is fresh**.
|
|
56
61
|
2. **`map_scope {task}`** — the task in words → the edit set, the governing rules, the covering tests
|
|
@@ -503,15 +508,46 @@ them**:
|
|
|
503
508
|
|
|
504
509
|
| You see it | When |
|
|
505
510
|
|---|---|
|
|
511
|
+
| 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** |
|
|
512
|
+
| `jarvis work peers` | you asked again, because that line was true when it printed and not for long |
|
|
506
513
|
| `next` skips a task | its `code:` regions are held by another session |
|
|
507
514
|
| `move <name> in-progress` | a live claim on it belongs to somebody else — a **note**, not a refusal |
|
|
508
515
|
| `status` → IN FLIGHT | a task is held by a session that is not you |
|
|
516
|
+
| a board read | every holder carries whether its run is still going and what reaches it |
|
|
509
517
|
| any write | a pull brought board changes in: *the board moved under you — <what> · from <who>* |
|
|
510
518
|
|
|
511
519
|
- **`— this machine`** → that session is addressable. `ListAgents` lists it, `SendMessage` reaches it.
|
|
512
520
|
Use it when the plan has to change and waiting for a lease to expire is the wrong answer.
|
|
513
521
|
- **`— another machine`** → it cannot be reached from here. The board is the only thing you share, so
|
|
514
522
|
say it on the board: `ask`, a `handoff`, or a task note.
|
|
523
|
+
- **`ENDED`** → that is abandoned work, not a peer to negotiate with. Nobody is there to ask, so the
|
|
524
|
+
decision is yours alone.
|
|
525
|
+
- **two processes on ONE session** → opening a running session in a second local client starts a
|
|
526
|
+
second process on the same id rather than attaching to the first. Both are alive and both append to
|
|
527
|
+
one transcript, so **either name reaches the same conversation** — you are handed both precisely
|
|
528
|
+
because neither is THE address.
|
|
529
|
+
|
|
530
|
+
### The three answers, and what to do with each
|
|
531
|
+
|
|
532
|
+
**Nobody.** Carry on. You were told, and a session that was told nothing is not the same as one that
|
|
533
|
+
never looked.
|
|
534
|
+
|
|
535
|
+
**Somebody, reachable.** Ask them before you scope, not after you collide — one message costs a turn,
|
|
536
|
+
and the collision costs a re-read or a whole gate run. Say what your edit set is and ask for theirs.
|
|
537
|
+
Then **split by GOAL, never by file**: two sessions carving one goal into halves produce two halves
|
|
538
|
+
nobody can ship. If their goal contains yours, hand it over rather than taking it — a `handoff` and a
|
|
539
|
+
message is cheaper than two people converging on one answer from opposite directions. If your edit
|
|
540
|
+
sets genuinely overlap, **sequence and say so**: commit in chunks, tell them when a shared tree is
|
|
541
|
+
clear, and do not interleave.
|
|
542
|
+
|
|
543
|
+
**Somebody, on another machine.** You cannot reach them at all, so the board is the whole of what you
|
|
544
|
+
share. Put it there — `ask`, a note on the task, a `handoff` — and then decide for yourself. Waiting
|
|
545
|
+
on somebody who cannot hear you is not caution.
|
|
546
|
+
|
|
547
|
+
**Nothing here refuses you, and nothing here is permission.** Taking always succeeds; you are told
|
|
548
|
+
who else is there and you decide. A harness that argues gets worked around — the one mechanism in
|
|
549
|
+
this repo that refuses produced a hand-written forty-iteration retry loop — so the whole of this is
|
|
550
|
+
*tell clearly, never refuse*.
|
|
515
551
|
|
|
516
552
|
**The harness never messages anyone.** It reports who and whether they are reachable; opening the
|
|
517
553
|
conversation is your call. A `move` that warns still moves — `next` is the door that declines held
|