harnex 0.29.1 → 0.30.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/agents/gpt.md +16 -8
- data/docs/agents.md +5 -2
- data/docs/lanes.md +52 -6
- data/lib/harnex/commands/ask.rb +11 -1
- data/lib/harnex/commands/delegate.rb +24 -3
- data/lib/harnex/commands/reply.rb +2 -1
- data/lib/harnex/commands/result.rb +1 -1
- data/lib/harnex/commands/send.rb +2 -1
- data/lib/harnex/commands/wait.rb +2 -1
- data/lib/harnex/config.rb +21 -0
- data/lib/harnex/core.rb +11 -0
- data/lib/harnex/lanes.rb +210 -15
- data/lib/harnex/mcp.rb +5 -3
- data/lib/harnex/runtime/api_server.rb +13 -2
- data/lib/harnex/runtime/inbox.rb +3 -1
- data/lib/harnex/runtime/message.rb +1 -1
- data/lib/harnex/runtime/session.rb +72 -9
- data/lib/harnex/version.rb +2 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4b50e456b0b9be717e037a0596b942273e2e4c192b80d697fc502a66cc4c220
|
|
4
|
+
data.tar.gz: 77ed9e03656bfff5f68064bb4dcb64f898d1de6b5d599ad30450ac51f8a1938e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85ca4102b12fe2a0e109c1fdfc643c96d80677b267c0b658cb4ff5787a72075e0d77e9d98a29bf401fa5975502cfc5fca891fe62c63ef51a92b8f626e2c60d04
|
|
7
|
+
data.tar.gz: 39b143e75ade8b68254742f6ea23eee43fde615c01de9c273d221b681b8b913f7c35e87b6bf9a79f84e290602790641fdb5a96e84c7a75fedad602b1ef6718d6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0] - 2026-10-25 | 02:55 AM | IST
|
|
4
|
+
|
|
5
|
+
Holm's first nights with lanes (Holm Queue 167): #89–#92. Local install.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Lane names collided** (#89). Identical briefs in sibling worktrees of a
|
|
10
|
+
bare repo all became `pi-read-carry-out-brief`, and `harnex result NAME`
|
|
11
|
+
then failed with `several lanes are named …`.
|
|
12
|
+
- A bare layout (`repo/.bare` plus sibling worktrees) is one repo: its main
|
|
13
|
+
checkout is the worktree on the bare repo's `HEAD` branch, so every
|
|
14
|
+
worktree shares one lane namespace and `harnex lanes` shows them all.
|
|
15
|
+
- Generated names skip every name another lane still holds in any repo
|
|
16
|
+
(working, uncollected, or an unlanded worktree); `--name`, `--worktree`
|
|
17
|
+
and `ask --id` refuse a name while another repo's worker runs under it;
|
|
18
|
+
`result`/`reply` find a working lane from anywhere.
|
|
19
|
+
- A lane working in another worktree is named after it
|
|
20
|
+
(`pi-night-1024_s3`); brief openers such as "You are a … lane" and "carry
|
|
21
|
+
out the brief" no longer make the name.
|
|
22
|
+
- `delegate --worktree` refuses a `--cwd` that is already another worktree
|
|
23
|
+
of the repo (it would have branched from the main checkout instead).
|
|
24
|
+
- **The `gpt` agent invented a `worktree`** from brief prose (#89). It now
|
|
25
|
+
sets `worktree`, `name` and `cwd` only from header lines, takes a new
|
|
26
|
+
`name:` header, and passes `cwd:` on to `result` and `reply`.
|
|
27
|
+
- **A lane in place reported other people's commits** (#91). Its footer
|
|
28
|
+
counts only commits that appeared while the worker ran a git command, and
|
|
29
|
+
names the rest: `0 commits (HEAD also moved 6 commits by others)`.
|
|
30
|
+
- **Messages sent straight to a worker were invisible** to the session that
|
|
31
|
+
started its lane (#92). `harnex send` now records who sent a message, and a
|
|
32
|
+
lane's result lists messages from anyone but the lane's owner and its own
|
|
33
|
+
`harnex reply` (`interjected: 1 message … from a terminal (harnex send):
|
|
34
|
+
"…"`). When a message reached the worker after it had answered, the result
|
|
35
|
+
shows the answers it replaced first, the original task's report among them.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `.harnex/config.json` `lanes` templates put worktree lanes where the repo
|
|
40
|
+
keeps its worktrees (#90): `{"lanes": {"worktree_path": "../{name}",
|
|
41
|
+
"branch": "night/{mm}{dd}_{name}"}}` (`{name}`, `{yyyy}`, `{mm}`, `{dd}`).
|
|
42
|
+
`land` and `discard` follow each lane's own path and branch.
|
|
43
|
+
- MCP `ask` takes `name`.
|
|
44
|
+
|
|
3
45
|
## [0.29.1] - 2026-10-23 | 10:31 AM | IST
|
|
4
46
|
|
|
5
|
-
|
|
47
|
+
Fix release for #88: lanes in repos with a phase allowlist. Published to
|
|
48
|
+
RubyGems on 2026-10-25 | 02:14 AM | IST.
|
|
6
49
|
|
|
7
50
|
### Fixed
|
|
8
51
|
|
data/agents/gpt.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gpt
|
|
3
|
-
description: "Hand a task or question to a GPT model (a Pi worker run through harnex) and get its reply back verbatim. Use when the user asks for GPT, for another model's opinion or review, or to delegate work to Pi/GPT workers; launch several at once for parallel GPT work, and SendMessage the same agent for follow-ups (the worker keeps its context). While it runs, the harnex `lanes` tool or `harnex lanes` shows what each worker is doing right now (its current tool). Questions and reviews go to a read-only worker; a brief that asks to run commands, tests or benchmarks, or to change files, goes to a worker that can (in the current checkout). The prompt may open with header lines, then a blank line, then the brief: `worktree: NAME` (edit in a new git worktree on branch harnex/NAME; afterwards land it with the harnex `land` tool or `harnex land NAME`, which merges and removes the worktree and branch; don't git merge lane branches by hand), `mode: write` (run commands or edit the current checkout in place), `mode: read` (read-only even if the brief mentions commands), `model: provider/model`, `effort: low|medium|high`, `fallback: provider/model` (read-only asks), `cwd: <dir
|
|
3
|
+
description: "Hand a task or question to a GPT model (a Pi worker run through harnex) and get its reply back verbatim. Use when the user asks for GPT, for another model's opinion or review, or to delegate work to Pi/GPT workers; launch several at once for parallel GPT work, and SendMessage the same agent for follow-ups (the worker keeps its context). While it runs, the harnex `lanes` tool or `harnex lanes` shows what each worker is doing right now (its current tool). Questions and reviews go to a read-only worker; a brief that asks to run commands, tests or benchmarks, or to change files, goes to a worker that can (in the current checkout). The prompt may open with header lines, then a blank line, then the brief: `worktree: NAME` (edit in a new git worktree on branch harnex/NAME; afterwards land it with the harnex `land` tool or `harnex land NAME`, which merges and removes the worktree and branch; don't git merge lane branches by hand), `mode: write` (run commands or edit the current checkout in place), `mode: read` (read-only even if the brief mentions commands), `model: provider/model`, `effort: low|medium|high`, `fallback: provider/model` (read-only asks), `cwd: <dir>` (work in that directory, e.g. a sibling worktree), `name: NAME` (the lane's name; default: the cwd worktree's name or the brief's first words). Write a self-contained brief: the GPT worker has not seen this conversation."
|
|
4
4
|
tools: mcp__harnex__ask, mcp__harnex__delegate, mcp__harnex__reply, mcp__harnex__result
|
|
5
5
|
mcpServers:
|
|
6
6
|
- harnex
|
|
@@ -16,11 +16,16 @@ return its text.
|
|
|
16
16
|
## 1. Split the prompt
|
|
17
17
|
|
|
18
18
|
The prompt may open with header lines `key: value` using only these keys:
|
|
19
|
-
`worktree`, `mode`, `model`, `effort`, `fallback`, `cwd`. The header
|
|
20
|
-
the first line that is not one of them; drop one blank line after it.
|
|
19
|
+
`worktree`, `mode`, `model`, `effort`, `fallback`, `cwd`, `name`. The header
|
|
20
|
+
ends at the first line that is not one of them; drop one blank line after it.
|
|
21
21
|
Everything after the header is the brief, kept exactly as written. No header
|
|
22
22
|
lines means the whole prompt is the brief.
|
|
23
23
|
|
|
24
|
+
Only header lines set tool arguments. Never take a `worktree`, `name` or
|
|
25
|
+
`cwd` from the brief's wording, even when the brief names a worktree, a
|
|
26
|
+
branch or a directory: the brief goes to the worker as it is, and the worker
|
|
27
|
+
reads it.
|
|
28
|
+
|
|
24
29
|
## 2. Call one tool
|
|
25
30
|
|
|
26
31
|
Pick the tool:
|
|
@@ -35,23 +40,26 @@ Then:
|
|
|
35
40
|
|
|
36
41
|
- **`delegate`:** call it with
|
|
37
42
|
- `task` = the brief;
|
|
38
|
-
- `worktree` = NAME, if
|
|
43
|
+
- `worktree` = NAME, only if the header has a `worktree:` line;
|
|
44
|
+
- `name` = NAME, if the header has a `name:` line and no `worktree:` line;
|
|
39
45
|
- and `model`, `effort`, `cwd` if given.
|
|
40
46
|
- **`ask`:** call it with
|
|
41
47
|
- `question` = the brief;
|
|
42
|
-
- and `model`, `effort`, `cwd` if given, plus `fallback_model` from
|
|
48
|
+
- and `model`, `effort`, `cwd`, `name` if given, plus `fallback_model` from
|
|
43
49
|
`fallback:`.
|
|
44
50
|
- **A later message in this conversation** (a follow-up) goes to `reply`, with
|
|
45
51
|
- `name` = the lane named in the last result's `── NAME:` footer;
|
|
46
|
-
- `message` = the new message, exactly as written
|
|
52
|
+
- `message` = the new message, exactly as written;
|
|
53
|
+
- and `cwd` if the first prompt's header gave one.
|
|
47
54
|
|
|
48
55
|
Pass every string exactly as written.
|
|
49
56
|
|
|
50
57
|
## 3. Still working
|
|
51
58
|
|
|
52
59
|
If the tool's text ends with a footer saying `still working`, call `result`
|
|
53
|
-
with `names` = [that lane name]
|
|
54
|
-
times. Never start a second lane for
|
|
60
|
+
with `names` = [that lane name], and `cwd` if the header gave one. Repeat
|
|
61
|
+
while it says still working, up to 12 times. Never start a second lane for
|
|
62
|
+
the same brief.
|
|
55
63
|
|
|
56
64
|
## 4. Return
|
|
57
65
|
|
data/docs/agents.md
CHANGED
|
@@ -59,10 +59,13 @@ Optional header lines, then a blank line, then the brief:
|
|
|
59
59
|
| `mode: read` | Read-only, even if the brief mentions commands |
|
|
60
60
|
| `model: provider/model`, `effort: low\|medium\|high` | Pick the worker's model |
|
|
61
61
|
| `fallback: provider/model` | Retry a read-only ask on another model |
|
|
62
|
-
| `cwd: DIR` | Work in another directory |
|
|
62
|
+
| `cwd: DIR` | Work in another directory, e.g. a sibling worktree (follow-ups stay there) |
|
|
63
|
+
| `name: NAME` | Name the lane (default: the `cwd` worktree's name, else the brief's first words) |
|
|
63
64
|
|
|
64
65
|
Without a header, questions and reviews go to a read-only worker, and a brief
|
|
65
|
-
that asks to run something or change files goes to a worker that can.
|
|
66
|
+
that asks to run something or change files goes to a worker that can. Only
|
|
67
|
+
header lines set these: a brief that mentions a worktree or a branch is passed
|
|
68
|
+
on as it is, never turned into a `worktree`.
|
|
66
69
|
The definition ships with harnex as [`agents/gpt.md`](../agents/gpt.md).
|
|
67
70
|
|
|
68
71
|
### The CLI from Claude's Bash tool
|
data/docs/lanes.md
CHANGED
|
@@ -5,7 +5,8 @@ its follow-ups. You start one with `harnex ask` or `harnex delegate`, collect
|
|
|
5
5
|
it with `harnex result`, continue it with `harnex reply`, and finish it with
|
|
6
6
|
`harnex land` or `harnex discard`. Every lane has a name, runs in a visible
|
|
7
7
|
tmux window named after it, and leaves a small record so any later command can
|
|
8
|
-
find it by name from anywhere in the repository
|
|
8
|
+
find it by name from anywhere in the repository (from any worktree of it, a
|
|
9
|
+
bare-repo layout included).
|
|
9
10
|
|
|
10
11
|
Lanes are the agent-facing layer of harnex. Everything here is also available
|
|
11
12
|
as native tools through [`harnex mcp`](mcp.md), and the low-level `run`, `send`,
|
|
@@ -65,10 +66,19 @@ cost, commits and the next commands.
|
|
|
65
66
|
`harnex/NAME` from `HEAD`. The worker is told to commit there. Parallel lanes
|
|
66
67
|
never collide, and your checkout is untouched until you land. If your checkout
|
|
67
68
|
has uncommitted changes, harnex warns that the worktree starts from `HEAD`.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
A repo that keeps its worktrees elsewhere says so in `.harnex/config.json`
|
|
70
|
+
(see [Where things live](#where-things-live)).
|
|
71
|
+
- **Without `--worktree`** the worker works in the current directory, or in
|
|
72
|
+
`--cwd DIR`, which is right for tasks that only run or inspect things (tests,
|
|
73
|
+
a benchmark, a long command) and for a worktree you made yourself.
|
|
74
|
+
- **Names.** `--name NAME` names the lane. Otherwise a lane working in another
|
|
75
|
+
worktree of the repo is named after it (`--cwd ../night-1024_s3` gives
|
|
76
|
+
`pi-night-1024_s3`), and any other lane after the task's first words
|
|
77
|
+
(`pi-slow-bench`, then `pi-slow-bench-2`). Generated names skip every name
|
|
78
|
+
another lane still holds anywhere (working, uncollected, or a worktree not
|
|
79
|
+
yet landed), and a name you choose is refused while another repo's worker
|
|
80
|
+
is running under it, so `harnex result NAME` and `reply NAME` find a
|
|
81
|
+
working lane from anywhere.
|
|
72
82
|
- **`--file PATH`** reads a long brief from a file. Briefs must be
|
|
73
83
|
self-contained: the worker has not seen your conversation. Name the files, the
|
|
74
84
|
expected behavior and how to verify it.
|
|
@@ -126,6 +136,11 @@ or bare `land`.
|
|
|
126
136
|
`harnex discard NAME` stops the lane if it is still working and deletes its
|
|
127
137
|
worktree and branch without merging.
|
|
128
138
|
|
|
139
|
+
**Lanes in place.** For a lane without a worktree, the footer counts only the
|
|
140
|
+
commits the worker made (harnex watches `HEAD` around each git command the
|
|
141
|
+
worker runs). Commits someone else made in the same checkout meanwhile are
|
|
142
|
+
named apart: `0 commits (HEAD also moved 3 commits by others)`.
|
|
143
|
+
|
|
129
144
|
## Parallel lanes
|
|
130
145
|
|
|
131
146
|
```bash
|
|
@@ -153,6 +168,26 @@ mid-task as a follow-up; a lane that has finished is relaunched on its own Pi
|
|
|
153
168
|
session (`pi --session`), so it remembers everything from the earlier turns.
|
|
154
169
|
The footer's cost is the lane's total so far.
|
|
155
170
|
|
|
171
|
+
**Messages from someone else.** Anyone can `harnex send` to a lane's worker
|
|
172
|
+
(the owner from another terminal, a buddy). The lane's result then says so,
|
|
173
|
+
so the session that started the lane is not left guessing:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
[earlier reply this turn, before another message reached the worker]
|
|
177
|
+
Review: PASS (0 P1, 2 P2) ...
|
|
178
|
+
|
|
179
|
+
[latest reply]
|
|
180
|
+
Understood; the report is now in /tmp/review.md.
|
|
181
|
+
|
|
182
|
+
── review: done in 6m10s · gpt-6.1-sol · $0.41 · 0 commits
|
|
183
|
+
interjected: 1 message reached the worker from outside this lane (the reply above may answer it; all: `harnex events --id review`):
|
|
184
|
+
23:26:04 from a terminal (harnex send): "Write the report to /tmp/review.md instead"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The worker's earlier answers in the same turn (the original task's report
|
|
188
|
+
among them) come first; your own `harnex reply` follow-ups are never listed as
|
|
189
|
+
interjections.
|
|
190
|
+
|
|
156
191
|
## When a model fails
|
|
157
192
|
|
|
158
193
|
```text
|
|
@@ -188,7 +223,18 @@ worker's tmux window, and the window itself is always there to watch.
|
|
|
188
223
|
repo, worktree, branch, model, owner, Pi session, cost, last result). Closed
|
|
189
224
|
lanes are pruned after 30 days; open lanes never are.
|
|
190
225
|
- **Worktrees:** `.harnex/worktrees/NAME` inside your repository, on branch
|
|
191
|
-
`harnex/NAME`.
|
|
226
|
+
`harnex/NAME`. To keep them beside the main checkout instead, set templates
|
|
227
|
+
in the repo's `.harnex/config.json` (`{name}`, `{yyyy}`, `{mm}`, `{dd}`;
|
|
228
|
+
the path is relative to the main checkout). `land` and `discard` follow the
|
|
229
|
+
lane's own path and branch:
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{ "lanes": { "worktree_path": "../night-{mm}{dd}_{name}", "branch": "night/{mm}{dd}_{name}" } }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
- **Bare-repo layouts** (`repo/.bare` plus sibling worktrees): the main
|
|
236
|
+
checkout is the worktree on the bare repo's `HEAD` branch (`master`), and
|
|
237
|
+
every worktree of the repo shares its lanes.
|
|
192
238
|
- **`.harnex/`** is added to the clone's `.git/info/exclude` the first time
|
|
193
239
|
harnex writes there, so lane worktrees and dispatch rows never show up as
|
|
194
240
|
untracked work. Nothing tracked changes.
|
data/lib/harnex/commands/ask.rb
CHANGED
|
@@ -112,6 +112,9 @@ module Harnex
|
|
|
112
112
|
|
|
113
113
|
def register_lane(id, model)
|
|
114
114
|
cwd = File.expand_path(@options[:cwd] || Dir.pwd)
|
|
115
|
+
if (other = Lanes.live_holder(id))
|
|
116
|
+
raise OptionParser::InvalidArgument, "a worker named #{id} is working in #{other['repo_root']}; pick another --id"
|
|
117
|
+
end
|
|
115
118
|
Lanes.save(
|
|
116
119
|
"name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
|
|
117
120
|
"cwd" => cwd, "read_only" => !@options[:write], "owner" => Harnex.ambient_owner_id,
|
|
@@ -204,7 +207,8 @@ module Harnex
|
|
|
204
207
|
answer = full_answer(turn)
|
|
205
208
|
case exit_code
|
|
206
209
|
when 0
|
|
207
|
-
|
|
210
|
+
lane = answered_lane(result["id"])
|
|
211
|
+
@out.puts(lane ? Lanes.render_answer(lane, turn.merge("final_message" => answer.to_s, "final_message_truncated" => false)) : answer.to_s)
|
|
208
212
|
@err.puts("harnex ask: #{result['id']} answered in #{elapsed.round}s (model #{turn['model'] || 'Pi default'})")
|
|
209
213
|
when Waiter::DONE_EXIT_TIMEOUT
|
|
210
214
|
@err.puts("harnex ask: #{result['id']} is still working; keep waiting with: #{resume_command(result['id'])}")
|
|
@@ -216,6 +220,12 @@ module Harnex
|
|
|
216
220
|
exit_code
|
|
217
221
|
end
|
|
218
222
|
|
|
223
|
+
def answered_lane(id)
|
|
224
|
+
id && Lanes.resolve(id, from: File.expand_path(@options[:cwd] || Dir.pwd))
|
|
225
|
+
rescue OptionParser::InvalidArgument
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
219
229
|
def full_answer(turn)
|
|
220
230
|
path = turn["final_message_path"].to_s
|
|
221
231
|
return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
|
|
@@ -17,8 +17,12 @@ module Harnex
|
|
|
17
17
|
Options:
|
|
18
18
|
--worktree NAME Work in a new git worktree (.harnex/worktrees/NAME, branch
|
|
19
19
|
harnex/NAME); finish with `harnex land NAME` or
|
|
20
|
-
`harnex discard NAME
|
|
21
|
-
|
|
20
|
+
`harnex discard NAME`. The repo's .harnex/config.json
|
|
21
|
+
can move it: {"lanes": {"worktree_path": "../{name}",
|
|
22
|
+
"branch": "night/{mm}{dd}_{name}"}}
|
|
23
|
+
--name NAME Lane name without a worktree (default: the --cwd
|
|
24
|
+
worktree's name, else from the task). A working
|
|
25
|
+
lane's name is unique across every repo
|
|
22
26
|
--cwd DIR Directory the worker works in (default: current directory)
|
|
23
27
|
--file PATH Read the task from PATH
|
|
24
28
|
--model NAME Pi model (provider/model); default: Pi's own default
|
|
@@ -86,9 +90,10 @@ module Harnex
|
|
|
86
90
|
root = Lanes.main_root(workdir)
|
|
87
91
|
raise OptionParser::InvalidArgument, "--worktree needs a git repository (#{workdir} is not in one)" if @options[:worktree] && root.nil?
|
|
88
92
|
|
|
93
|
+
refuse_nested_worktree!(root, workdir) if @options[:worktree]
|
|
89
94
|
Lanes.sweep_merged(root)
|
|
90
95
|
root ||= workdir
|
|
91
|
-
name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task))
|
|
96
|
+
name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task, cwd: workdir))
|
|
92
97
|
ensure_free!(root, name)
|
|
93
98
|
lane = {
|
|
94
99
|
"name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
|
|
@@ -125,11 +130,27 @@ module Harnex
|
|
|
125
130
|
"lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
|
|
126
131
|
"or `harnex land #{name}` / `harnex discard #{name}` first"
|
|
127
132
|
end
|
|
133
|
+
if (other = Lanes.live_holder(name)) && other["repo_root"] != root
|
|
134
|
+
raise OptionParser::InvalidArgument,
|
|
135
|
+
"a worker named #{name} is working in #{other['repo_root']}; working lanes' names are unique, " \
|
|
136
|
+
"so pick another name (or wait: `harnex result #{name}`)"
|
|
137
|
+
end
|
|
128
138
|
return unless Harnex.read_registry(root, name)
|
|
129
139
|
|
|
130
140
|
raise OptionParser::InvalidArgument, "a harnex session named #{name} is running; pick another name"
|
|
131
141
|
end
|
|
132
142
|
|
|
143
|
+
# #89: --cwd in a sibling worktree plus --worktree would branch a new
|
|
144
|
+
# worktree from the main checkout, not from --cwd; say so instead.
|
|
145
|
+
def refuse_nested_worktree!(root, workdir)
|
|
146
|
+
top = Lanes.toplevel(workdir)
|
|
147
|
+
return if top.nil? || root.nil? || Lanes.same_dir?(top, root)
|
|
148
|
+
|
|
149
|
+
raise OptionParser::InvalidArgument,
|
|
150
|
+
"--cwd #{top} is already a worktree of #{root}: drop --worktree to work there in place, " \
|
|
151
|
+
"or drop --cwd to start a new worktree from the main checkout"
|
|
152
|
+
end
|
|
153
|
+
|
|
133
154
|
def warn_uncommitted(root)
|
|
134
155
|
dirty = Lanes.dirty_paths(root)
|
|
135
156
|
return if dirty.empty?
|
|
@@ -61,7 +61,8 @@ module Harnex
|
|
|
61
61
|
def follow_up(lane, message)
|
|
62
62
|
argv = @harnex_command + ["send", "--id", lane["name"], "--repo", lane["run_root"], "--message", message]
|
|
63
63
|
argv += ["--wait-done"] + (@options[:timeout] ? ["--timeout", @options[:timeout].to_s] : []) if @options[:wait]
|
|
64
|
-
|
|
64
|
+
# Marked as the lane's own follow-up, not an interjection (#92).
|
|
65
|
+
stdout, stderr, status = Open3.capture3({ "HARNEX_SEND_VIA" => "reply" }, *argv)
|
|
65
66
|
result = Lanes.last_json(stdout)
|
|
66
67
|
raise Lanes::LaunchError, "harnex could not reach lane #{lane['name']}: #{stderr.strip.lines.last}" if result.empty?
|
|
67
68
|
|
|
@@ -71,7 +71,7 @@ module Harnex
|
|
|
71
71
|
text = if @options[:json]
|
|
72
72
|
JSON.generate(lane: lane["name"], exit_code: exit_code, state: lane["state"], result: turn)
|
|
73
73
|
elsif lane["kind"] == "ask" && exit_code.zero?
|
|
74
|
-
Lanes.
|
|
74
|
+
Lanes.render_answer(lane, turn)
|
|
75
75
|
else
|
|
76
76
|
Lanes.render(lane, turn, exit_code)
|
|
77
77
|
end
|
data/lib/harnex/commands/send.rb
CHANGED
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -488,7 +488,8 @@ module Harnex
|
|
|
488
488
|
}
|
|
489
489
|
payload[:turn] = event["turn"] if event["turn"]
|
|
490
490
|
payload[:follow_ups] = event["follow_ups"] if event["follow_ups"]
|
|
491
|
-
%w[end_sha commits turn_commits model final_message final_message_path final_message_truncated
|
|
491
|
+
%w[end_sha commits turn_commits model final_message final_message_path final_message_truncated
|
|
492
|
+
earlier_replies].each do |key|
|
|
492
493
|
payload[key.to_sym] = event[key] if event.key?(key)
|
|
493
494
|
end
|
|
494
495
|
exit_code = 0
|
data/lib/harnex/config.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Harnex
|
|
|
6
6
|
PHASE_POLICIES = %w[warn reject].freeze
|
|
7
7
|
RETENTION_DIRS = %w[events output receipts].freeze
|
|
8
8
|
RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
|
|
9
|
+
LANE_TEMPLATES = %w[worktree_path branch].freeze
|
|
9
10
|
RETENTION_ENV = {
|
|
10
11
|
"events" => {
|
|
11
12
|
"max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
|
|
@@ -38,6 +39,10 @@ module Harnex
|
|
|
38
39
|
def retention
|
|
39
40
|
data.is_a?(Hash) ? data["retention"] : nil
|
|
40
41
|
end
|
|
42
|
+
|
|
43
|
+
def lanes
|
|
44
|
+
data.is_a?(Hash) ? data["lanes"] : nil
|
|
45
|
+
end
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
module_function
|
|
@@ -63,6 +68,22 @@ module Harnex
|
|
|
63
68
|
|
|
64
69
|
validate_phase!(document["phase"], path) if document.key?("phase")
|
|
65
70
|
validate_retention!(document["retention"], path) if document.key?("retention")
|
|
71
|
+
validate_lanes!(document["lanes"], path) if document.key?("lanes")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# #90: where worktree lanes live and what their branches are called,
|
|
75
|
+
# e.g. {"worktree_path": "../{name}", "branch": "night/{mm}{dd}_{name}"}.
|
|
76
|
+
def validate_lanes!(lanes, path)
|
|
77
|
+
raise ConfigError, "#{path}: $.lanes must be an object" unless lanes.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
lanes.each do |key, template|
|
|
80
|
+
unless LANE_TEMPLATES.include?(key)
|
|
81
|
+
raise ConfigError, "#{path}: $.lanes.#{key} is not supported (expected #{LANE_TEMPLATES.join(' or ')})"
|
|
82
|
+
end
|
|
83
|
+
next if template.is_a?(String) && template.include?("{name}")
|
|
84
|
+
|
|
85
|
+
raise ConfigError, "#{path}: $.lanes.#{key} must be a string containing {name}"
|
|
86
|
+
end
|
|
66
87
|
end
|
|
67
88
|
|
|
68
89
|
def validate_phase!(phase, path)
|
data/lib/harnex/core.rb
CHANGED
|
@@ -450,6 +450,17 @@ module Harnex
|
|
|
450
450
|
nil
|
|
451
451
|
end
|
|
452
452
|
|
|
453
|
+
# #92: who is sending a message, for the receiving lane's footer. `via`
|
|
454
|
+
# is "reply" when `harnex reply` sends on the lane's behalf
|
|
455
|
+
# (HARNEX_SEND_VIA), else "send".
|
|
456
|
+
def sender_identity(env = ENV)
|
|
457
|
+
{
|
|
458
|
+
"via" => env["HARNEX_SEND_VIA"].to_s.strip.empty? ? "send" : env["HARNEX_SEND_VIA"].strip,
|
|
459
|
+
"session" => env["HARNEX_ID"].to_s.strip,
|
|
460
|
+
"owner" => ambient_owner_id(env).to_s
|
|
461
|
+
}.reject { |_key, value| value.empty? }
|
|
462
|
+
end
|
|
463
|
+
|
|
453
464
|
# How to invoke this harnex in a printed hint: the checkout's bin when
|
|
454
465
|
# running from source, plain `harnex` for the installed gem.
|
|
455
466
|
def cli_command
|
data/lib/harnex/lanes.rb
CHANGED
|
@@ -74,20 +74,45 @@ module Harnex
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# A name resolves to this repo's lane first, then to the only lane
|
|
77
|
-
# anywhere
|
|
77
|
+
# anywhere whose worker is running (those names are unique, #89), then
|
|
78
|
+
# in flight, open, or at all.
|
|
78
79
|
def resolve(name, from: Dir.pwd)
|
|
79
80
|
root = main_root(from)
|
|
80
81
|
lane = root && load(root, name)
|
|
81
82
|
return lane if lane
|
|
82
83
|
|
|
83
84
|
matches = all.select { |candidate| candidate["name"] == name }
|
|
84
|
-
return matches.first if matches.one?
|
|
85
85
|
raise OptionParser::InvalidArgument, "no lane named #{name.inspect} (see `harnex lanes`)" if matches.empty?
|
|
86
86
|
|
|
87
|
+
[matches.select { |l| open?(l) && live?(l) }, matches.select { |l| in_flight?(l) },
|
|
88
|
+
matches.select { |l| open?(l) }, matches].each do |group|
|
|
89
|
+
return group.first if group.one?
|
|
90
|
+
end
|
|
87
91
|
raise OptionParser::InvalidArgument,
|
|
88
92
|
"several lanes are named #{name.inspect} (#{matches.map { |l| l['repo_root'] }.join(', ')}); run from the lane's repo"
|
|
89
93
|
end
|
|
90
94
|
|
|
95
|
+
# Working (or finished but uncollected), or a worktree not yet landed,
|
|
96
|
+
# in a repo that still exists.
|
|
97
|
+
def in_flight?(lane)
|
|
98
|
+
open?(lane) && !never_started?(lane) && (lane["state"] == "running" || !lane["branch"].nil?) &&
|
|
99
|
+
File.directory?(lane["repo_root"].to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# #89: the in-flight lane anywhere that holds `name`, if any. Generated
|
|
103
|
+
# names skip these, so `harnex result NAME` and `reply NAME` find their
|
|
104
|
+
# lane from anywhere.
|
|
105
|
+
def holder(name)
|
|
106
|
+
all.find { |lane| lane["name"] == name && in_flight?(lane) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The lane anywhere whose worker is running under `name`, if any. A name
|
|
110
|
+
# someone chooses is refused only while another repo's worker has it, so
|
|
111
|
+
# stale lanes elsewhere (an old eval, an abandoned clone) never block it.
|
|
112
|
+
def live_holder(name)
|
|
113
|
+
all.find { |lane| lane["name"] == name && open?(lane) && live?(lane) }
|
|
114
|
+
end
|
|
115
|
+
|
|
91
116
|
# Lanes this caller started in this repo whose current turn nobody has
|
|
92
117
|
# collected yet.
|
|
93
118
|
def pending(from: Dir.pwd, owner: Harnex.ambient_owner_id)
|
|
@@ -98,20 +123,32 @@ module Harnex
|
|
|
98
123
|
|
|
99
124
|
FILLER = %w[a an the this that these those to of in on at for from with and or but it its please
|
|
100
125
|
run use add make have has can could would should will do does just then also your you
|
|
101
|
-
our my me i we gpt worker workers task repo repository git command file files code
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
126
|
+
our my me i we gpt worker workers task repo repository git command file files code
|
|
127
|
+
are is be carry out brief lane].freeze
|
|
128
|
+
|
|
129
|
+
# A lane working in another worktree of the repo is named after that
|
|
130
|
+
# worktree (`pi-night-1024_s3`); otherwise after the task's first words.
|
|
131
|
+
# Either way no repo has the name in flight (#89).
|
|
132
|
+
def generate_name(root, task, cwd: nil, prefix: "pi")
|
|
133
|
+
base = ([prefix] + name_words(root, task, cwd)).join("-")[0, 40].sub(/[-_.]+\z/, "")
|
|
106
134
|
name = base
|
|
107
135
|
index = 2
|
|
108
|
-
while open?(load(root, name)) || Harnex.read_registry(root, name)
|
|
136
|
+
while open?(load(root, name)) || holder(name) || Harnex.read_registry(root, name)
|
|
109
137
|
name = "#{base}-#{index}"
|
|
110
138
|
index += 1
|
|
111
139
|
end
|
|
112
140
|
name
|
|
113
141
|
end
|
|
114
142
|
|
|
143
|
+
def name_words(root, task, cwd)
|
|
144
|
+
top = cwd && toplevel(cwd)
|
|
145
|
+
if top && root && !same_dir?(top, root)
|
|
146
|
+
slug = File.basename(top).downcase.gsub(/[^a-z0-9._-]+/, "-").sub(/\A[^a-z0-9]+/, "")
|
|
147
|
+
return [slug] unless slug.empty?
|
|
148
|
+
end
|
|
149
|
+
task.to_s.downcase.scan(/[a-z0-9]+/).reject { |word| FILLER.include?(word) || word.match?(/\A[a-z]\z/) }.first(4)
|
|
150
|
+
end
|
|
151
|
+
|
|
115
152
|
def live?(lane)
|
|
116
153
|
registry = Harnex.read_registry(lane["run_root"], lane["name"])
|
|
117
154
|
!registry.nil? && Harnex.alive_pid?(registry["pid"])
|
|
@@ -155,17 +192,43 @@ module Harnex
|
|
|
155
192
|
end
|
|
156
193
|
|
|
157
194
|
# The main checkout of the repo that contains `path` (also from inside one
|
|
158
|
-
# of its worktrees), or nil outside git.
|
|
195
|
+
# of its worktrees), or nil outside git. In a bare layout (`repo/.bare`
|
|
196
|
+
# plus sibling worktrees, #89) it is the worktree on the bare repo's HEAD
|
|
197
|
+
# branch, so every worktree of the repo shares one lane namespace.
|
|
159
198
|
def main_root(path)
|
|
160
199
|
common, _err, ok = git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
161
200
|
return nil unless ok
|
|
162
201
|
|
|
163
202
|
return File.dirname(common) if File.basename(common) == ".git"
|
|
164
203
|
|
|
204
|
+
bare_main_worktree(path, common) || toplevel(path)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def same_dir?(one, two)
|
|
208
|
+
File.realpath(one.to_s) == File.realpath(two.to_s)
|
|
209
|
+
rescue SystemCallError
|
|
210
|
+
File.expand_path(one.to_s) == File.expand_path(two.to_s)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def toplevel(path)
|
|
165
214
|
top, _err, ok = git(path, "rev-parse", "--show-toplevel")
|
|
166
215
|
ok ? top : nil
|
|
167
216
|
end
|
|
168
217
|
|
|
218
|
+
def bare_main_worktree(path, common)
|
|
219
|
+
list, _err, ok = git(path, "worktree", "list", "--porcelain")
|
|
220
|
+
return nil unless ok
|
|
221
|
+
|
|
222
|
+
entries = list.split(/\n{2,}/).map do |block|
|
|
223
|
+
block.lines.to_h { |line| line.chomp.split(" ", 2).then { |key, value| [key, value.to_s] } }
|
|
224
|
+
end
|
|
225
|
+
return nil unless entries.first&.key?("bare")
|
|
226
|
+
|
|
227
|
+
head, _err, ok = git(common, "symbolic-ref", "-q", "HEAD")
|
|
228
|
+
main = ok && entries.find { |entry| entry["branch"] == head && File.directory?(entry["worktree"].to_s) }
|
|
229
|
+
main ? main["worktree"] : nil
|
|
230
|
+
end
|
|
231
|
+
|
|
169
232
|
def head_sha(dir)
|
|
170
233
|
sha, _err, ok = git(dir, "rev-parse", "HEAD")
|
|
171
234
|
ok ? sha : nil
|
|
@@ -176,10 +239,28 @@ module Harnex
|
|
|
176
239
|
ok ? out.lines.map { |line| line[3..].to_s.strip } : []
|
|
177
240
|
end
|
|
178
241
|
|
|
242
|
+
# #90: a worktree lane lives at .harnex/worktrees/NAME on harnex/NAME,
|
|
243
|
+
# unless the repo's .harnex/config.json `lanes` templates say otherwise
|
|
244
|
+
# ({name}, {yyyy}, {mm}, {dd}; worktree_path is relative to the main
|
|
245
|
+
# checkout, so "../{name}" puts lanes beside it).
|
|
246
|
+
def worktree_layout(root, name, now: Time.now)
|
|
247
|
+
templates = Config.load_repo(root).lanes || {}
|
|
248
|
+
fill = lambda do |template|
|
|
249
|
+
template.gsub("{name}", name).gsub("{yyyy}", now.strftime("%Y")).gsub("{mm}", now.strftime("%m"))
|
|
250
|
+
.gsub("{dd}", now.strftime("%d"))
|
|
251
|
+
end
|
|
252
|
+
path = fill.call(templates["worktree_path"] || File.join(WORKTREES_DIR, "{name}"))
|
|
253
|
+
[File.expand_path(path, root), fill.call(templates["branch"] || "#{BRANCH_PREFIX}{name}")]
|
|
254
|
+
rescue Config::ConfigError => e
|
|
255
|
+
raise OptionParser::InvalidArgument, "invalid config #{e.message}"
|
|
256
|
+
end
|
|
257
|
+
|
|
179
258
|
def create_worktree(root, name)
|
|
180
259
|
Harnex.ensure_git_excluded(root)
|
|
181
|
-
worktree =
|
|
182
|
-
|
|
260
|
+
worktree, branch = worktree_layout(root, name)
|
|
261
|
+
_out, _err, valid = git(root, "check-ref-format", "--branch", branch)
|
|
262
|
+
raise OptionParser::InvalidArgument, "lanes.branch gives #{branch.inspect}, which is not a valid branch name" unless valid
|
|
263
|
+
|
|
183
264
|
_out, _err, exists = git(root, "rev-parse", "--verify", "--quiet", "refs/heads/#{branch}")
|
|
184
265
|
if exists || File.exist?(worktree)
|
|
185
266
|
raise OptionParser::InvalidArgument,
|
|
@@ -259,8 +340,12 @@ module Harnex
|
|
|
259
340
|
merged
|
|
260
341
|
end
|
|
261
342
|
|
|
343
|
+
# Inside the checkout: "a/b"; beside it: "../x"; anywhere else: absolute.
|
|
262
344
|
def relative(root, path)
|
|
263
|
-
path.to_s.
|
|
345
|
+
return path.to_s.delete_prefix("#{root}/") if path.to_s.start_with?("#{root}/")
|
|
346
|
+
|
|
347
|
+
sibling = "../#{File.basename(path.to_s)}"
|
|
348
|
+
File.expand_path(sibling, root.to_s) == File.expand_path(path.to_s) ? sibling : path.to_s
|
|
264
349
|
end
|
|
265
350
|
|
|
266
351
|
# ---- briefs ------------------------------------------------------------
|
|
@@ -419,7 +504,7 @@ module Harnex
|
|
|
419
504
|
lane["settled_at"] = now
|
|
420
505
|
# Kept so a later `harnex result NAME` can show the result again.
|
|
421
506
|
lane["last_turn"] = turn.slice("final_message", "final_message_path", "final_message_truncated", "model",
|
|
422
|
-
"outcome_class", "status", "last_error", "end_sha")
|
|
507
|
+
"outcome_class", "status", "last_error", "end_sha", "earlier_replies")
|
|
423
508
|
save(lane)
|
|
424
509
|
end
|
|
425
510
|
|
|
@@ -436,13 +521,79 @@ module Harnex
|
|
|
436
521
|
|
|
437
522
|
def render(lane, turn, exit_code)
|
|
438
523
|
lines = []
|
|
439
|
-
message = final_message(turn)
|
|
524
|
+
message = with_earlier_replies(final_message(turn), turn)
|
|
440
525
|
lines << message.rstrip << "" unless message.strip.empty? || exit_code == Waiter::DONE_EXIT_TIMEOUT
|
|
441
526
|
lines << "── #{lane['name']}: #{headline(lane, turn, exit_code)}"
|
|
442
|
-
lines.concat(details(lane, exit_code).map { |line| " #{line}" })
|
|
527
|
+
lines.concat((interjection_lines(lane) + details(lane, exit_code)).map { |line| " #{line}" })
|
|
443
528
|
lines.join("\n")
|
|
444
529
|
end
|
|
445
530
|
|
|
531
|
+
# An answered ask prints only its answer, plus what else reached the
|
|
532
|
+
# worker (#92).
|
|
533
|
+
def render_answer(lane, turn)
|
|
534
|
+
text = with_earlier_replies(final_message(turn), turn)
|
|
535
|
+
notes = interjection_lines(lane)
|
|
536
|
+
return text if notes.empty?
|
|
537
|
+
|
|
538
|
+
[text.rstrip, "", "── #{lane['name']}: #{notes.first}", *notes.drop(1).map { |line| " #{line}" }].join("\n")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# #92: when a later message reached the worker after it had answered,
|
|
542
|
+
# the turn's result is its answer to that message; the answers it
|
|
543
|
+
# replaced (the original task's report among them) come first.
|
|
544
|
+
def with_earlier_replies(message, turn)
|
|
545
|
+
earlier = Array(turn["earlier_replies"]).map(&:to_s).reject { |text| text.strip.empty? }
|
|
546
|
+
return message if earlier.empty? || message.strip.empty?
|
|
547
|
+
|
|
548
|
+
parts = earlier.map { |text| "[earlier reply this turn, before another message reached the worker]\n#{text.rstrip}" }
|
|
549
|
+
(parts + ["[latest reply]\n#{message.rstrip}"]).join("\n\n")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# #92: messages that reached the worker from anyone but the lane's own
|
|
553
|
+
# owner and its `harnex reply` (someone's `harnex send` from another
|
|
554
|
+
# terminal or session), since the lane's latest launch.
|
|
555
|
+
def interjections(lane)
|
|
556
|
+
events(lane).select do |event|
|
|
557
|
+
event["type"] == "send" && event["from"].is_a?(Hash) && after?(event["ts"], lane["launched_at"]) &&
|
|
558
|
+
!own_message?(lane, event["from"])
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def own_message?(lane, from)
|
|
563
|
+
owner = lane["owner"].to_s
|
|
564
|
+
from["owner"].to_s == owner && (!owner.empty? || from["via"] == "reply")
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def interjection_lines(lane)
|
|
568
|
+
found = interjections(lane)
|
|
569
|
+
return [] if found.empty?
|
|
570
|
+
|
|
571
|
+
count = found.length == 1 ? "1 message" : "#{found.length} messages"
|
|
572
|
+
["interjected: #{count} reached the worker from outside this lane " \
|
|
573
|
+
"(the reply above may answer #{found.length == 1 ? 'it' : 'them'}; all: `harnex events --id #{lane['name']}`):"] +
|
|
574
|
+
found.last(5).map { |event| " #{clock(event['ts'])} #{sender_label(event['from'])}: #{preview(event)}" }
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def sender_label(from)
|
|
578
|
+
who = if from["session"] then "harnex session #{from['session']}"
|
|
579
|
+
elsif from["owner"] then "session #{from['owner'][0, 8]}"
|
|
580
|
+
else "a terminal"
|
|
581
|
+
end
|
|
582
|
+
"from #{who} (harnex #{from['via'] || 'send'})"
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def preview(event)
|
|
586
|
+
text = event["msg"].to_s.sub(/\A\[harnex [^\]]*\]\s*/, "").gsub(/\s+/, " ").strip
|
|
587
|
+
cut = text.length > 160 || event["msg_truncated"]
|
|
588
|
+
"\"#{text[0, 160]}#{cut ? '…' : ''}\""
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def clock(ts)
|
|
592
|
+
Time.iso8601(ts.to_s).localtime.strftime("%H:%M:%S")
|
|
593
|
+
rescue ArgumentError
|
|
594
|
+
ts.to_s
|
|
595
|
+
end
|
|
596
|
+
|
|
446
597
|
def headline(lane, turn, exit_code)
|
|
447
598
|
model = turn["model"] || lane["model"] || lane["requested_model"]
|
|
448
599
|
parts = []
|
|
@@ -465,6 +616,9 @@ module Harnex
|
|
|
465
616
|
dir = lane["worktree"] || lane["cwd"]
|
|
466
617
|
return nil unless lane["base_sha"] && File.directory?(dir.to_s)
|
|
467
618
|
|
|
619
|
+
shas = lane["branch"] ? nil : worker_commits(lane)
|
|
620
|
+
return worker_change_summary(lane, dir, shas) if shas
|
|
621
|
+
|
|
468
622
|
count, _err, ok = git(dir, "rev-list", "--count", "#{lane['base_sha']}..HEAD")
|
|
469
623
|
return nil unless ok
|
|
470
624
|
|
|
@@ -473,6 +627,47 @@ module Harnex
|
|
|
473
627
|
stat.empty? ? commits : "#{commits}, #{compact_stat(stat)}"
|
|
474
628
|
end
|
|
475
629
|
|
|
630
|
+
# #91: a lane working in place shares HEAD with whoever else commits
|
|
631
|
+
# there, so count only the commits the worker made (worker_commits
|
|
632
|
+
# events). nil when the worker's harnex did not track them.
|
|
633
|
+
def worker_commits(lane)
|
|
634
|
+
found = events(lane).select { |event| after?(event["ts"], lane["created_at"]) }
|
|
635
|
+
return nil unless found.any? { |event| event["type"] == "git" && event["tracks_worker_commits"] }
|
|
636
|
+
|
|
637
|
+
found.select { |event| event["type"] == "worker_commits" }.flat_map { |event| Array(event["shas"]) }
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def worker_change_summary(lane, dir, shas)
|
|
641
|
+
moved, _err, ok = git(dir, "rev-list", "#{lane['base_sha']}..HEAD")
|
|
642
|
+
return nil unless ok
|
|
643
|
+
|
|
644
|
+
history = moved.split
|
|
645
|
+
mine = history & shas
|
|
646
|
+
text = "#{mine.length} commit#{mine.length == 1 ? '' : 's'}"
|
|
647
|
+
stat = mine.empty? ? nil : commits_stat(dir, mine)
|
|
648
|
+
text += ", #{stat}" if stat
|
|
649
|
+
others = history.length - mine.length
|
|
650
|
+
text += " (HEAD also moved #{others} commit#{others == 1 ? '' : 's'} by others)" if others.positive?
|
|
651
|
+
text
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def commits_stat(dir, shas)
|
|
655
|
+
out, _err, ok = git(dir, "show", "--numstat", "--format=", *shas)
|
|
656
|
+
return nil unless ok
|
|
657
|
+
|
|
658
|
+
files = {}
|
|
659
|
+
added = removed = 0
|
|
660
|
+
out.each_line do |line|
|
|
661
|
+
plus, minus, path = line.chomp.split("\t", 3)
|
|
662
|
+
next unless path
|
|
663
|
+
|
|
664
|
+
files[path] = true
|
|
665
|
+
added += plus.to_i
|
|
666
|
+
removed += minus.to_i
|
|
667
|
+
end
|
|
668
|
+
"#{files.size} file#{files.size == 1 ? '' : 's'} (+#{added} -#{removed})"
|
|
669
|
+
end
|
|
670
|
+
|
|
476
671
|
def compact_stat(stat)
|
|
477
672
|
files = stat[/(\d+) files? changed/, 1]
|
|
478
673
|
added = stat[/(\d+) insertions?/, 1]
|
data/lib/harnex/mcp.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Harnex
|
|
|
41
41
|
"model" => MODEL, "effort" => EFFORT,
|
|
42
42
|
"fallback_model" => { "type" => "string", "description" => "Ask this model instead if the first is rate limited" },
|
|
43
43
|
"write" => { "type" => "boolean", "description" => "Let the worker edit files and run commands (default false)" },
|
|
44
|
+
"name" => { "type" => "string", "description" => "Lane name (default: pi-ask-<random>)" },
|
|
44
45
|
"cwd" => CWD, "wait_minutes" => WAIT_MINUTES
|
|
45
46
|
} },
|
|
46
47
|
"annotations" => { "readOnlyHint" => false, "openWorldHint" => true }
|
|
@@ -56,7 +57,7 @@ module Harnex
|
|
|
56
57
|
"inputSchema" => { "type" => "object", "required" => ["task"], "properties" => {
|
|
57
58
|
"task" => { "type" => "string", "description" => "Self-contained brief: files, expected behavior, how to verify (e.g. the test command)" },
|
|
58
59
|
"worktree" => { "type" => "string", "description" => "Lane name; work in a new git worktree on branch harnex/NAME (recommended for edits)" },
|
|
59
|
-
"name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: from the task)" },
|
|
60
|
+
"name" => { "type" => "string", "description" => "Lane name when not using a worktree (default: the cwd worktree's name, else from the task)" },
|
|
60
61
|
"wait" => { "type" => "boolean", "description" => "Wait for the result (default true); false starts the lane and returns at once" },
|
|
61
62
|
"model" => MODEL, "effort" => EFFORT, "cwd" => CWD, "wait_minutes" => WAIT_MINUTES
|
|
62
63
|
} },
|
|
@@ -250,10 +251,11 @@ module Harnex
|
|
|
250
251
|
case tool
|
|
251
252
|
when "delegate"
|
|
252
253
|
if args["worktree"].to_s.empty? && args["name"].to_s.empty?
|
|
253
|
-
|
|
254
|
-
args["name"] = Lanes.generate_name(
|
|
254
|
+
dir = work_dir(args)
|
|
255
|
+
args["name"] = Lanes.generate_name(Lanes.main_root(dir) || dir, args["task"] || args["prompt"] || args["brief"], cwd: dir)
|
|
255
256
|
end
|
|
256
257
|
when "ask"
|
|
258
|
+
args["id"] = args["name"] unless args["name"].to_s.strip.empty?
|
|
257
259
|
args["id"] ||= "pi-ask-#{SecureRandom.hex(2)}"
|
|
258
260
|
end
|
|
259
261
|
args
|
|
@@ -78,7 +78,8 @@ module Harnex
|
|
|
78
78
|
text: payload[:text],
|
|
79
79
|
submit: payload[:submit],
|
|
80
80
|
enter_only: payload[:enter_only],
|
|
81
|
-
force: payload[:force]
|
|
81
|
+
force: payload[:force],
|
|
82
|
+
from: payload[:from]
|
|
82
83
|
)
|
|
83
84
|
http_code = result.delete(:http_status) || 200
|
|
84
85
|
json(client, http_code, result)
|
|
@@ -136,6 +137,15 @@ module Harnex
|
|
|
136
137
|
context
|
|
137
138
|
end
|
|
138
139
|
|
|
140
|
+
# Who sent a message (#92), as `harnex send` reports it: bounded strings.
|
|
141
|
+
def sender(value)
|
|
142
|
+
return nil unless value.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
value.slice("via", "session", "owner").filter_map do |key, text|
|
|
145
|
+
[key, text.to_s[0, 120]] unless text.to_s.strip.empty?
|
|
146
|
+
end.to_h
|
|
147
|
+
end
|
|
148
|
+
|
|
139
149
|
def parse_send_body(headers, body)
|
|
140
150
|
if headers["content-type"].to_s.include?("application/json")
|
|
141
151
|
parsed = JSON.parse(body.empty? ? "{}" : body)
|
|
@@ -145,7 +155,8 @@ module Harnex
|
|
|
145
155
|
text: parsed["text"].to_s,
|
|
146
156
|
submit: parsed.fetch("submit", true),
|
|
147
157
|
enter_only: parsed.fetch("enter_only", false),
|
|
148
|
-
force: parsed.fetch("force", false)
|
|
158
|
+
force: parsed.fetch("force", false),
|
|
159
|
+
from: sender(parsed["from"])
|
|
149
160
|
}
|
|
150
161
|
else
|
|
151
162
|
{
|
data/lib/harnex/runtime/inbox.rb
CHANGED
|
@@ -32,13 +32,14 @@ module Harnex
|
|
|
32
32
|
@thread&.kill
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def enqueue(text:, submit:, enter_only:, force: false)
|
|
35
|
+
def enqueue(text:, submit:, enter_only:, force: false, from: nil)
|
|
36
36
|
msg = Message.new(
|
|
37
37
|
id: SecureRandom.hex(8),
|
|
38
38
|
text: text,
|
|
39
39
|
submit: submit,
|
|
40
40
|
enter_only: enter_only,
|
|
41
41
|
force: force,
|
|
42
|
+
from: from,
|
|
42
43
|
queued_at: Time.now,
|
|
43
44
|
status: :queued
|
|
44
45
|
)
|
|
@@ -139,6 +140,7 @@ module Harnex
|
|
|
139
140
|
def deliver_now(msg, follow_up: false)
|
|
140
141
|
options = { text: msg.text, submit: msg.submit, enter_only: msg.enter_only, force: msg.force }
|
|
141
142
|
options[:follow_up] = true if follow_up
|
|
143
|
+
options[:from] = msg.from if msg.from
|
|
142
144
|
result = @session.inject_via_adapter(**options)
|
|
143
145
|
msg.status = :delivered
|
|
144
146
|
msg.delivered_at = Time.now
|
|
@@ -61,6 +61,7 @@ module Harnex
|
|
|
61
61
|
MAX_PENDING_PI_COMMANDS = 256
|
|
62
62
|
# Inline cap for a turn's final reply; the full text is in its file.
|
|
63
63
|
FINAL_MESSAGE_LIMIT = 4000
|
|
64
|
+
MAX_EARLIER_REPLIES = 3
|
|
64
65
|
class EventCounters
|
|
65
66
|
def initialize
|
|
66
67
|
@counts = {
|
|
@@ -171,6 +172,7 @@ module Harnex
|
|
|
171
172
|
@turn_follow_ups_sent = 0
|
|
172
173
|
@pi_follow_up_pending = 0
|
|
173
174
|
@turn_final_text = nil
|
|
175
|
+
@turn_earlier_replies = []
|
|
174
176
|
@completion_notifier = completion_notifier || CompletionNotifier.new(
|
|
175
177
|
repo_root: repo_root,
|
|
176
178
|
id: @id,
|
|
@@ -190,6 +192,7 @@ module Harnex
|
|
|
190
192
|
@observed_commands_mutex = Mutex.new
|
|
191
193
|
@observed_commands = []
|
|
192
194
|
@pi_pending_commands = {}
|
|
195
|
+
@pi_git_heads = {}
|
|
193
196
|
@pi_unobserved_commands = 0
|
|
194
197
|
# Final gates and the failure flag cover the whole command history; the
|
|
195
198
|
# retained @observed_commands window is capped at MAX_COMMANDS (#68).
|
|
@@ -480,10 +483,12 @@ module Harnex
|
|
|
480
483
|
{ ok: true, signal: "exit_sequence_sent" }
|
|
481
484
|
end
|
|
482
485
|
|
|
483
|
-
|
|
486
|
+
# `from` names who sent a message through `harnex send` (#92); the
|
|
487
|
+
# launch prompt has none.
|
|
488
|
+
def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
|
|
484
489
|
if structured_transport?
|
|
485
490
|
return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force,
|
|
486
|
-
follow_up: follow_up)
|
|
491
|
+
follow_up: follow_up, from: from)
|
|
487
492
|
end
|
|
488
493
|
|
|
489
494
|
after_seq = current_event_seq
|
|
@@ -509,7 +514,7 @@ module Harnex
|
|
|
509
514
|
force: payload[:force],
|
|
510
515
|
after_seq: after_seq
|
|
511
516
|
)
|
|
512
|
-
.tap { emit_send_event(text, force: payload[:force]) }
|
|
517
|
+
.tap { emit_send_event(text, force: payload[:force], from: from) }
|
|
513
518
|
end
|
|
514
519
|
|
|
515
520
|
# #79: a structured adapter that can queue a message into the running turn
|
|
@@ -525,7 +530,7 @@ module Harnex
|
|
|
525
530
|
@events_mutex.synchronize { @events_log_seq }
|
|
526
531
|
end
|
|
527
532
|
|
|
528
|
-
def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false)
|
|
533
|
+
def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
|
|
529
534
|
if @runtime_budget&.expired?
|
|
530
535
|
stop_for_runtime_budget!
|
|
531
536
|
raise ArgumentError, "session runtime budget is exhausted"
|
|
@@ -560,6 +565,8 @@ module Harnex
|
|
|
560
565
|
# is reset here. If Pi has in fact gone idle it starts a new run,
|
|
561
566
|
# whose agent_start begins the next turn.
|
|
562
567
|
unless queued_into_run
|
|
568
|
+
# A new prompt answers nothing earlier in this turn (#92).
|
|
569
|
+
@pi_last_answer = nil
|
|
563
570
|
# Reserve work before the blocking RPC. Steering an active run does
|
|
564
571
|
# not reset its clocks, but a new prompt cannot reuse old proof.
|
|
565
572
|
@activity.start_turn
|
|
@@ -584,7 +591,7 @@ module Harnex
|
|
|
584
591
|
end
|
|
585
592
|
end
|
|
586
593
|
|
|
587
|
-
emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force])
|
|
594
|
+
emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force], from: from)
|
|
588
595
|
{
|
|
589
596
|
ok: true,
|
|
590
597
|
cli: adapter.key,
|
|
@@ -930,6 +937,7 @@ module Harnex
|
|
|
930
937
|
adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
|
|
931
938
|
schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
|
|
932
939
|
when "message_start"
|
|
940
|
+
note_pi_user_message(message["message"])
|
|
933
941
|
@pi_active_message_key = pi_message_key(message["message"])
|
|
934
942
|
@pi_streamed_text_by_message[@pi_active_message_key] = false
|
|
935
943
|
when "message_update"
|
|
@@ -955,6 +963,7 @@ module Harnex
|
|
|
955
963
|
if pi_command_tool?(message)
|
|
956
964
|
@event_counters.record_item({ "type" => "commandExecution" })
|
|
957
965
|
start_pi_command(message)
|
|
966
|
+
note_pi_git_head(message)
|
|
958
967
|
else
|
|
959
968
|
@event_counters.record_item({ "type" => "dynamicToolCall" })
|
|
960
969
|
end
|
|
@@ -962,7 +971,10 @@ module Harnex
|
|
|
962
971
|
"tool: #{message["toolName"] || "tool"}#{message["args"] ? " #{summarize(message["args"])}" : ""}"
|
|
963
972
|
)
|
|
964
973
|
when "tool_execution_end"
|
|
965
|
-
|
|
974
|
+
if pi_command_tool?(message)
|
|
975
|
+
finish_pi_command(message)
|
|
976
|
+
record_pi_worker_commits(message)
|
|
977
|
+
end
|
|
966
978
|
tool_name = message["toolName"] || "tool"
|
|
967
979
|
status = message["isError"] ? "error" : "ok"
|
|
968
980
|
record_synthesized("tool-result: #{tool_name} (#{status})")
|
|
@@ -1198,6 +1210,9 @@ module Harnex
|
|
|
1198
1210
|
|
|
1199
1211
|
text = pi_extract_message_text(message).to_s
|
|
1200
1212
|
@turn_final_text = text unless text.strip.empty?
|
|
1213
|
+
# An answer (stopReason "stop"), kept apart from the run outcome that
|
|
1214
|
+
# every turn_start resets (#92).
|
|
1215
|
+
@pi_last_answer = message["stopReason"] == "stop" && !text.strip.empty? ? text : nil
|
|
1201
1216
|
|
|
1202
1217
|
reason = message["stopReason"].to_s
|
|
1203
1218
|
@pi_last_assistant_stop_reason = reason unless reason.empty?
|
|
@@ -1205,6 +1220,49 @@ module Harnex
|
|
|
1205
1220
|
@pi_last_assistant_error = error unless error.empty?
|
|
1206
1221
|
end
|
|
1207
1222
|
|
|
1223
|
+
# #92: a message that reaches the worker after it has answered (a
|
|
1224
|
+
# follow-up from the lane's owner or from anyone else) starts a new
|
|
1225
|
+
# answer; keep the one it replaces so the turn's result still has it.
|
|
1226
|
+
def note_pi_user_message(message)
|
|
1227
|
+
return unless message.is_a?(Hash) && message["role"] == "user"
|
|
1228
|
+
|
|
1229
|
+
text = @pi_last_answer.to_s.strip
|
|
1230
|
+
@pi_last_answer = nil
|
|
1231
|
+
return if text.empty? || @turn_earlier_replies.last == text
|
|
1232
|
+
|
|
1233
|
+
@turn_earlier_replies = (@turn_earlier_replies + [text]).last(MAX_EARLIER_REPLIES)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
# #91: commits the worker made, told apart from HEAD moving under it
|
|
1237
|
+
# (an orchestrator committing in the same checkout): HEAD is read around
|
|
1238
|
+
# each command that runs git, and only commits that appear during one
|
|
1239
|
+
# count as the worker's.
|
|
1240
|
+
def note_pi_git_head(message)
|
|
1241
|
+
args = message["args"]
|
|
1242
|
+
command = args["command"] if args.is_a?(Hash)
|
|
1243
|
+
return unless command.is_a?(String) && command.match?(/\bgit\b/) && @git_start && !@git_start.empty?
|
|
1244
|
+
|
|
1245
|
+
head = observed_git_head
|
|
1246
|
+
@pi_git_heads[message["toolCallId"].to_s] = head if head
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def record_pi_worker_commits(message)
|
|
1250
|
+
before = @pi_git_heads.delete(message["toolCallId"].to_s)
|
|
1251
|
+
after = before && observed_git_head
|
|
1252
|
+
return if after.nil? || after == before
|
|
1253
|
+
|
|
1254
|
+
shas = Harnex.git_output(git_observation_root, "rev-list", "--max-count=100", "#{before}..#{after}").split
|
|
1255
|
+
emit_event("worker_commits", shas: shas) unless shas.empty?
|
|
1256
|
+
rescue StandardError
|
|
1257
|
+
nil
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
def observed_git_head
|
|
1261
|
+
Harnex.git_output(git_observation_root, "rev-parse", "HEAD")
|
|
1262
|
+
rescue StandardError
|
|
1263
|
+
nil
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1208
1266
|
def settle_pi_task!
|
|
1209
1267
|
return if @stop_requested || @pi_transport_failed
|
|
1210
1268
|
|
|
@@ -1392,6 +1450,8 @@ module Harnex
|
|
|
1392
1450
|
@completion_notification_sent = false
|
|
1393
1451
|
@turn_follow_ups_sent = 0
|
|
1394
1452
|
@turn_final_text = nil
|
|
1453
|
+
@turn_earlier_replies = []
|
|
1454
|
+
@pi_last_answer = nil
|
|
1395
1455
|
@completion_turn += 1
|
|
1396
1456
|
end
|
|
1397
1457
|
return unless turn
|
|
@@ -1451,6 +1511,7 @@ module Harnex
|
|
|
1451
1511
|
end
|
|
1452
1512
|
fields = { final_message: text[0, FINAL_MESSAGE_LIMIT], final_message_path: path }
|
|
1453
1513
|
fields[:final_message_truncated] = true if text.length > FINAL_MESSAGE_LIMIT
|
|
1514
|
+
fields[:earlier_replies] = @turn_earlier_replies.map { |reply| reply[0, FINAL_MESSAGE_LIMIT] } if @turn_earlier_replies.any?
|
|
1454
1515
|
fields
|
|
1455
1516
|
rescue StandardError
|
|
1456
1517
|
{ final_message: text[0, FINAL_MESSAGE_LIMIT] }
|
|
@@ -1658,11 +1719,13 @@ module Harnex
|
|
|
1658
1719
|
warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
|
|
1659
1720
|
end
|
|
1660
1721
|
|
|
1661
|
-
def emit_send_event(text, force:)
|
|
1722
|
+
def emit_send_event(text, force:, from: nil)
|
|
1662
1723
|
compact = text.to_s
|
|
1663
1724
|
truncated = compact.length > 200
|
|
1664
1725
|
preview = truncated ? "#{compact[0, 200]}…" : compact
|
|
1665
|
-
|
|
1726
|
+
payload = { msg: preview, msg_truncated: truncated, forced: !!force }
|
|
1727
|
+
payload[:from] = from if from.is_a?(Hash) && !from.empty?
|
|
1728
|
+
emit_event("send", **payload)
|
|
1666
1729
|
end
|
|
1667
1730
|
|
|
1668
1731
|
def emit_started_event
|
|
@@ -1685,7 +1748,7 @@ module Harnex
|
|
|
1685
1748
|
)
|
|
1686
1749
|
return if @git_start.empty?
|
|
1687
1750
|
|
|
1688
|
-
payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch] }
|
|
1751
|
+
payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch], tracks_worker_commits: true }
|
|
1689
1752
|
payload[:git_root] = git_observation_root if git_lane?
|
|
1690
1753
|
emit_event("git", **payload)
|
|
1691
1754
|
end
|
data/lib/harnex/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jikku Jose
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Hand questions and tasks to Pi workers in visible tmux windows and get
|
|
14
14
|
the result back in one call: lanes in git worktrees, follow-ups that keep context,
|