jackherd 1.23.0 → 1.23.2
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/CHANGELOG.md +8 -0
- package/README.md +18 -18
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +4 -2
- package/package.json +1 -1
- package/src/plans/ensure-panes.ts +7 -7
- package/src/plans/plan-panes.ts +22 -0
- package/src/resume/command.ts +57 -17
- package/src/resume/decision.ts +26 -2
- package/src/resume/keeper.ts +32 -5
- package/src/resume/ports.ts +3 -0
- package/src/resume/record.ts +52 -2
- package/src/resume/targets.ts +60 -10
- package/src/resume/texts.ts +21 -5
- package/src/watch/command.ts +12 -3
- package/src/watch/lane-watch.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.23.2
|
|
6
|
+
|
|
7
|
+
- **After a reboot the coordinator is found without its name, and the herd comes back.** herdr keeps no name given with `herdr agent rename` over a restore, so `jh resume` found no coordinator and sent nothing. The watcher's resume record now also keeps the coordinator's agent session id and pane id, and the lanes' model columns; `jh resume` finds the coordinator by the session id (else the pane id) in the main checkout, sets the name again and logs it. Records of older versions work as before. Repositories are resumed at the same time, so one missing coordinator no longer holds up the others. herdr cannot tell which model a restored lane runs (it comes back without `--model`), so the log names each interrupted lane with its board model and the coordinator's line asks it to check those lanes against `lane-session.md`. `herd --ensure` leaves out a pane whose `process-info` fails (an old shell in a deleted directory) with one line, instead of failing for every lane.
|
|
8
|
+
|
|
9
|
+
## 1.23.1
|
|
10
|
+
|
|
11
|
+
- **A lane with uncommitted work can rebase after a merge.** The orchestrate skill (§ 5 and § 6) let a lane work uncommitted until its PR was ready and also told every lane to rebase after each merge; Git rebases no dirty tree and `git stash` is shared between worktrees. Such a lane now commits locally with `-F` (amended in later rounds), rebases, and runs the install command; push and PR still wait for the PR approval. Run `jh sync` to get the skill text.
|
|
12
|
+
|
|
5
13
|
## 1.23.0
|
|
6
14
|
|
|
7
15
|
- **`jh lane dev <lane>` starts a lane's dev server in one call.** It splits a pane beside the lane's agent, labels it `<lane> dev :<port>`, runs `PORT=<port> <command>` there and prints one line (pane, label, port, command). The port is `--port` or the port column of the lane's board row (`--board <board.md>` names the board), and a port something listens on is refused. A pane with that label in the lane's tab is reported and left alone. The command is the new `lanes.devCommand` in `.jackherd/config.json`, with `{port}` and `{filter}` (`--filter <package>`) filled in; without it the call refuses and names the key. The coordinator no longer chains `herdr pane split`, `rename` and `run`, which the merge guard asked about; `jh lane dev` passes it like any other `jh` call. Run `jh sync` to get the skill text.
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<p>
|
|
2
|
-
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.23.
|
|
2
|
+
<img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.23.2/assets/branding/jackherd-logo-outline.svg" width="340">
|
|
3
3
|
<br>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [](https://unpkg.com/jackherd/INSTALL.md) [](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.2/assets/icons/space.svg">
|
|
7
7
|
|
|
8
8
|
# Jack herds your coding agents.
|
|
9
9
|
|
|
@@ -15,29 +15,29 @@ For developers who'd rather decide than supervise.
|
|
|
15
15
|
curl -fsSL https://unpkg.com/jackherd/install.sh | sh
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.
|
|
18
|
+
Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.2/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.23.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.23.2/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
|
|
22
22
|
<br>
|
|
23
|
-
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.
|
|
23
|
+
<sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.2/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
|
|
27
27
|
|
|
28
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
|
|
29
29
|
|
|
30
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
|
|
31
31
|
|
|
32
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
|
|
33
33
|
|
|
34
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
|
|
35
35
|
|
|
36
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
|
|
37
37
|
|
|
38
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
38
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
|
|
39
39
|
|
|
40
|
-
<p><img src="https://unpkg.com/jackherd@1.23.
|
|
40
|
+
<p><img src="https://unpkg.com/jackherd@1.23.2/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.2/assets/icons/space.svg"></p>
|
|
41
41
|
|
|
42
42
|
## Quickstart
|
|
43
43
|
|
|
@@ -68,22 +68,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
|
|
|
68
68
|
/jh-orchestrate docs/plans/board-<name>/board.md
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.
|
|
71
|
+
Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.2/assets/icons/space.svg">
|
|
72
72
|
|
|
73
73
|
## Follow anything you can query
|
|
74
74
|
|
|
75
75
|
Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
|
|
76
76
|
|
|
77
|
-
<img src="https://unpkg.com/jackherd@1.23.
|
|
77
|
+
<img src="https://unpkg.com/jackherd@1.23.2/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
|
|
78
78
|
|
|
79
|
-
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.
|
|
79
|
+
The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.2/assets/icons/space.svg">
|
|
80
80
|
|
|
81
81
|
## In detail
|
|
82
82
|
|
|
83
83
|
<details>
|
|
84
84
|
<summary><b>What Jack does on his own, and what waits for you</b></summary>
|
|
85
85
|
|
|
86
|
-
<img src="https://unpkg.com/jackherd@1.23.
|
|
86
|
+
<img src="https://unpkg.com/jackherd@1.23.2/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
|
|
87
87
|
|
|
88
88
|
| On his own | After your approval | Never |
|
|
89
89
|
| --- | --- | --- |
|
|
@@ -118,7 +118,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
|
|
|
118
118
|
|
|
119
119
|
A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
|
|
120
120
|
|
|
121
|
-
<img src="https://unpkg.com/jackherd@1.23.
|
|
121
|
+
<img src="https://unpkg.com/jackherd@1.23.2/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
|
|
122
122
|
|
|
123
123
|
| Skill | Does |
|
|
124
124
|
| --- | --- |
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.23.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.23.2}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -268,7 +268,7 @@ before you open it.
|
|
|
268
268
|
|
|
269
269
|
## 5. Commits, push, PR
|
|
270
270
|
|
|
271
|
-
**One approval per PR, not per commit.** A lane works uncommitted until its PR is ready, then asks
|
|
271
|
+
**One approval per PR, not per commit.** A lane works uncommitted until its PR is ready (the local commit before a rebase, § 6, aside), then asks
|
|
272
272
|
once; that approval covers the commits, the push and opening the PR. The user merges every PR, unless the
|
|
273
273
|
project supplement names PRs Jack may merge itself (approval tiers), the user picked the self-merge option
|
|
274
274
|
(step 2), or a standing approval covers the lane's plan. When the user wants a plan's lanes merged without a question for a while, ask for or end one as [`references/standing-approval.md`](references/standing-approval.md) says.
|
|
@@ -278,7 +278,8 @@ sits on the one-time option, only for a step on this list:
|
|
|
278
278
|
|
|
279
279
|
- under the lane's PR approval: the commit, and the push of the lane's own branch to its own
|
|
280
280
|
remote branch, forced only with `--force-with-lease`
|
|
281
|
-
- the rebase after a merge (§ 6), which needs no approval of its own:
|
|
281
|
+
- the rebase after a merge (§ 6), which needs no approval of its own: for a lane with uncommitted work first
|
|
282
|
+
`git commit -F <file>` or `git commit --amend -F <file>` (§ 6); then `git fetch`,
|
|
282
283
|
`git rebase origin/{{kit.baseBranch}}` (`--onto origin/{{kit.baseBranch}} <old PR head>` for a branch over a
|
|
283
284
|
merged PR's), `git checkout --ours {{kit.lanes.lockFile}}`,
|
|
284
285
|
`git add {{kit.lanes.lockFile}}`, `{{kit.command.install}}`, `git rebase --continue`
|
|
@@ -342,6 +343,7 @@ the user every time.
|
|
|
342
343
|
`git rebase origin/{{kit.baseBranch}}`, as two commands, and to re-run before they push the checks the
|
|
343
344
|
watcher's duty line names after `merged #<n>`; whenever in doubt, all of them. A `{{kit.lanes.lockFile}}` conflict is never resolved by hand: take the base branch's
|
|
344
345
|
(`git checkout --ours {{kit.lanes.lockFile}}` during a rebase), run `{{kit.command.install}}`, continue.
|
|
346
|
+
- **A lane with uncommitted work takes the same rebase after each merge**, committing locally first with `-F` (one commit, amended in later rounds and at the PR approval), since Git rebases no dirty tree and `git stash` is shared between worktrees; its push and PR wait for the PR approval (§ 5), and its dev server runs on the current base branch.
|
|
345
347
|
- Merge order is the board's. A PR ready early waits for its turn, its lane goes on (§ 2); say so.
|
|
346
348
|
- **The documentation PR of § 2 step 0 merges first**, before any lane PR that started from it; then clean up
|
|
347
349
|
as in [`references/plan-commit.md`](references/plan-commit.md).
|
package/package.json
CHANGED
|
@@ -19,14 +19,14 @@ import {
|
|
|
19
19
|
splitPaneOf,
|
|
20
20
|
viewCommandOf,
|
|
21
21
|
withAgentPanes,
|
|
22
|
+
withForeground,
|
|
22
23
|
} from './plan-panes'
|
|
23
24
|
import type { PlansSettings } from './settings'
|
|
24
25
|
|
|
25
|
-
const panesWithForeground = (agentPanes: readonly string[]): PaneState[] =>
|
|
26
|
-
withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}))
|
|
26
|
+
const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[]; notes: string[] } =>
|
|
27
|
+
withForeground(withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes), (pane) =>
|
|
28
|
+
foregroundOf(runHerdr(['pane', 'process-info', '--pane', pane])),
|
|
29
|
+
)
|
|
30
30
|
|
|
31
31
|
/** The board's lanes that have a worktree, closed ones left out. */
|
|
32
32
|
const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
|
|
@@ -72,7 +72,7 @@ export const ensurePlanPaneRun = (
|
|
|
72
72
|
): { lines: string[]; exitCode: EnsureExit } => {
|
|
73
73
|
const agents = runHerdr(['agent', 'list'])
|
|
74
74
|
const listed = parseAgentList(agents)
|
|
75
|
-
const panes = panesWithForeground(agentPanesOf(agents))
|
|
75
|
+
const { panes, notes: skipped } = panesWithForeground(agentPanesOf(agents))
|
|
76
76
|
const coordinatorName = resolveCoordinatorName(
|
|
77
77
|
basename(root),
|
|
78
78
|
listed.map((agent) => agent.name),
|
|
@@ -91,7 +91,7 @@ export const ensurePlanPaneRun = (
|
|
|
91
91
|
command: (lane) => viewCommandOf(process.argv, lane),
|
|
92
92
|
missingCoordinator: missingCoordinatorNote(panes, root, coordinatorName),
|
|
93
93
|
})
|
|
94
|
-
return { lines: [...actions.map(apply), ...notes], exitCode: ensureExitOf(panes, coordinator) }
|
|
94
|
+
return { lines: [...skipped, ...actions.map(apply), ...notes], exitCode: ensureExitOf(panes, coordinator) }
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** The lines of `ensurePlanPaneRun`, for a caller that brings the panes back without an exit code (the watcher). */
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
+
import { errorText } from './error-text'
|
|
2
3
|
import { HERD_COMMAND, HERD_COMMAND_WORDS } from './herd-command'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -83,6 +84,27 @@ export const withAgentPanes = <Pane extends { pane: string; agent: boolean }>(
|
|
|
83
84
|
agentPanes: readonly string[],
|
|
84
85
|
): Pane[] => panes.map((each) => (agentPanes.includes(each.pane) ? { ...each, agent: true } : each))
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The panes with their foreground from `readForeground`; a pane it throws for is left out with one note. herdr lists
|
|
89
|
+
* panes it cannot answer for (old shells whose directory is gone answer `pane_not_found`), and one of them must not
|
|
90
|
+
* keep the herd from the others.
|
|
91
|
+
*/
|
|
92
|
+
export const withForeground = (
|
|
93
|
+
panes: readonly Omit<PaneState, 'foreground'>[],
|
|
94
|
+
readForeground: (pane: string) => string[][],
|
|
95
|
+
): { panes: PaneState[]; notes: string[] } => {
|
|
96
|
+
const kept: PaneState[] = []
|
|
97
|
+
const notes: string[] = []
|
|
98
|
+
for (const each of panes) {
|
|
99
|
+
try {
|
|
100
|
+
kept.push({ ...each, foreground: readForeground(each.pane) })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
notes.push(`${each.pane}: no process info, left out (${errorText(error)})`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { panes: kept, notes }
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
const isArgv = (value: unknown): value is string[] =>
|
|
87
109
|
Array.isArray(value) && value.every((arg) => typeof arg === 'string')
|
|
88
110
|
|
package/src/resume/command.ts
CHANGED
|
@@ -4,7 +4,17 @@ import { errorText } from '../plans/error-text'
|
|
|
4
4
|
import type { PlanLanguage } from '../vocabulary'
|
|
5
5
|
import { type ResumeDecision, resumeDecision } from './decision'
|
|
6
6
|
import { type ResumeRecord, resumedRecord } from './record'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type AgentIdentity,
|
|
9
|
+
type AgentPane,
|
|
10
|
+
type Streak,
|
|
11
|
+
steadyTarget,
|
|
12
|
+
TARGET_MISS,
|
|
13
|
+
TARGET_VIA,
|
|
14
|
+
type TargetMissed,
|
|
15
|
+
type TargetVia,
|
|
16
|
+
targetFor,
|
|
17
|
+
} from './targets'
|
|
8
18
|
import { resumeTexts } from './texts'
|
|
9
19
|
|
|
10
20
|
type Limits = { attempts: number; intervalMs: number }
|
|
@@ -34,6 +44,8 @@ export type ResumePorts = {
|
|
|
34
44
|
settingsOf: (root: string) => { language: PlanLanguage; worktreeDirectory: string }
|
|
35
45
|
/** herdr's agent panes; throws with herdr's error when it does not answer. */
|
|
36
46
|
agentPanes: () => AgentPane[]
|
|
47
|
+
/** Gives the agent in `pane` the herdr name `name` again (`herdr agent rename`); throws with herdr's error. */
|
|
48
|
+
rename: (pane: string, name: string) => void
|
|
37
49
|
send: SendPorts
|
|
38
50
|
sleep: (ms: number) => Promise<void>
|
|
39
51
|
log: (root: string, message: string) => void
|
|
@@ -42,7 +54,7 @@ export type ResumePorts = {
|
|
|
42
54
|
|
|
43
55
|
type Resume = Extract<ResumeDecision, { kind: 'resume' }>
|
|
44
56
|
|
|
45
|
-
type Recipient = { name: string; directory: string }
|
|
57
|
+
type Recipient = { name: string; directory: string; identity?: AgentIdentity }
|
|
46
58
|
|
|
47
59
|
type Search = { found: Map<Recipient, string>; missed: Map<Recipient, TargetMissed> }
|
|
48
60
|
|
|
@@ -78,20 +90,23 @@ const awaitTargets = async (
|
|
|
78
90
|
root: string,
|
|
79
91
|
recipients: readonly Recipient[],
|
|
80
92
|
limits: Limits,
|
|
81
|
-
onFound: (target: string) => Promise<void>,
|
|
93
|
+
onFound: (target: string, via: TargetVia) => Promise<void>,
|
|
82
94
|
): Promise<Search> => {
|
|
83
95
|
const search: Search = { found: new Map(), missed: new Map() }
|
|
84
96
|
const streaks = new Map<Recipient, Streak>()
|
|
85
97
|
for (let attempt = 1; search.found.size < recipients.length; attempt++) {
|
|
86
98
|
const panes = pollPanes(ports, root)
|
|
87
99
|
for (const recipient of recipients.filter((each) => !search.found.has(each))) {
|
|
88
|
-
const steady = steadyTarget(
|
|
100
|
+
const steady = steadyTarget(
|
|
101
|
+
targetFor(panes, recipient.name, recipient.directory, recipient.identity),
|
|
102
|
+
streaks.get(recipient) ?? null,
|
|
103
|
+
)
|
|
89
104
|
streaks.set(recipient, steady.streak)
|
|
90
105
|
if (steady.target.kind === 'none') search.missed.set(recipient, steady.target)
|
|
91
106
|
else {
|
|
92
107
|
search.found.set(recipient, steady.target.target)
|
|
93
108
|
search.missed.delete(recipient)
|
|
94
|
-
await onFound(steady.target.target)
|
|
109
|
+
await onFound(steady.target.target, steady.target.via)
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
if (search.found.size === recipients.length || attempt >= limits.attempts) break
|
|
@@ -131,10 +146,27 @@ const sendTo = async (ports: ResumePorts, root: string, target: string, text: st
|
|
|
131
146
|
ports.log(root, (await sendLanePrompt(ports.send, target, text)).line)
|
|
132
147
|
}
|
|
133
148
|
|
|
149
|
+
/** herdr does not keep a name given by `herdr agent rename` over a restore; a coordinator found without it gets it back. */
|
|
150
|
+
const renameCoordinator = async (ports: ResumePorts, decision: Resume, pane: string, via: TargetVia) => {
|
|
151
|
+
if (via !== TARGET_VIA.session && via !== TARGET_VIA.pane) return
|
|
152
|
+
try {
|
|
153
|
+
ports.rename(pane, decision.coordinator)
|
|
154
|
+
ports.log(decision.root, `coordinator ${decision.coordinator} found by its ${via} in ${pane}, name set again`)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
ports.log(decision.root, `coordinator found by its ${via} in ${pane}, rename failed: ${errorText(error)}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
/** The coordinator's target once it is back in the main checkout; null, logged, when it is not within the window. */
|
|
135
161
|
const awaitCoordinator = async (ports: ResumePorts, decision: Resume): Promise<string | null> => {
|
|
136
|
-
const recipient = {
|
|
137
|
-
|
|
162
|
+
const recipient: Recipient = {
|
|
163
|
+
name: decision.coordinator,
|
|
164
|
+
directory: decision.root,
|
|
165
|
+
...(decision.coordinatorIdentity ? { identity: decision.coordinatorIdentity } : {}),
|
|
166
|
+
}
|
|
167
|
+
const search = await awaitTargets(ports, decision.root, [recipient], RESUME_LIMITS.coordinator, (pane, via) =>
|
|
168
|
+
renameCoordinator(ports, decision, pane, via),
|
|
169
|
+
)
|
|
138
170
|
reportMisses(ports, decision.root, search.missed, (name) => `no coordinator ${name}`)
|
|
139
171
|
return search.found.get(recipient) ?? null
|
|
140
172
|
}
|
|
@@ -162,7 +194,10 @@ const resumeRepository = async (ports: ResumePorts, path: string, record: Resume
|
|
|
162
194
|
if (decision.unsafe.length > 0) ports.log(decision.root, `left out, no lane names: ${decision.unsafe.join(', ')}`)
|
|
163
195
|
const coordinator = await awaitCoordinator(ports, decision)
|
|
164
196
|
if (coordinator === null || !marked(ports, path, record)) return
|
|
165
|
-
const
|
|
197
|
+
const modelCheck = Object.keys(decision.laneModels)
|
|
198
|
+
for (const [name, model] of Object.entries(decision.laneModels))
|
|
199
|
+
ports.log(decision.root, `${name}: restored without --model, board model ${model}; the coordinator checks it`)
|
|
200
|
+
const line = texts.coordinatorLine(decision.board, decision.interrupted, decision.blocked, modelCheck)
|
|
166
201
|
await sendTo(ports, decision.root, coordinator, line)
|
|
167
202
|
await resumeLanes(ports, decision, worktreeDirectory, texts.laneText)
|
|
168
203
|
}
|
|
@@ -178,19 +213,24 @@ const resumeEntry = async (ports: ResumePorts, path: string, record: ResumeRecor
|
|
|
178
213
|
else await resumeRepository(ports, path, record, decision)
|
|
179
214
|
}
|
|
180
215
|
|
|
181
|
-
|
|
216
|
+
const resumeSafely = async (ports: ResumePorts, path: string, record: ResumeRecord | null) => {
|
|
217
|
+
try {
|
|
218
|
+
if (record === null) ports.log(path, 'damaged record, read as none')
|
|
219
|
+
else await resumeEntry(ports, path, record)
|
|
220
|
+
} catch (error) {
|
|
221
|
+
ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Every repository with a record at once, so one whose coordinator does not come back holds up no other; a failure in
|
|
227
|
+
* one is logged and the others go on. Every log line starts with its repository. Always 0.
|
|
228
|
+
*/
|
|
182
229
|
export const resumeAll = async (ports: ResumePorts): Promise<number> => {
|
|
183
230
|
try {
|
|
184
231
|
const entries = ports.records()
|
|
185
232
|
if (entries.length === 0) ports.log('-', 'no resume record')
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
if (record === null) ports.log(path, 'damaged record, read as none')
|
|
189
|
-
else await resumeEntry(ports, path, record)
|
|
190
|
-
} catch (error) {
|
|
191
|
-
ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
233
|
+
await Promise.all(entries.map(({ path, record }) => resumeSafely(ports, path, record)))
|
|
194
234
|
} catch (error) {
|
|
195
235
|
ports.log('-', `cannot read the records: ${errorText(error)}`)
|
|
196
236
|
}
|
package/src/resume/decision.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
2
|
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
3
3
|
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
4
|
-
import type { ResumeRecord } from './record'
|
|
4
|
+
import type { CoordinatorIdentity, ResumeRecord } from './record'
|
|
5
5
|
|
|
6
6
|
/** Why `jh resume` leaves a repository alone. */
|
|
7
7
|
export const RESUME_SKIP = {
|
|
@@ -45,6 +45,10 @@ export type ResumeDecision =
|
|
|
45
45
|
interrupted: string[]
|
|
46
46
|
/** The lanes that waited for an answer; only named to the coordinator. */
|
|
47
47
|
blocked: string[]
|
|
48
|
+
/** What finds the coordinator without its name; null for a record of an older version. */
|
|
49
|
+
coordinatorIdentity: CoordinatorIdentity | null
|
|
50
|
+
/** The interrupted lanes with a model column on record, each with that column made printable; only logged. */
|
|
51
|
+
laneModels: Record<string, string>
|
|
48
52
|
/** Lane names that are no single file name: never sent to, only logged. */
|
|
49
53
|
unsafe: string[]
|
|
50
54
|
}
|
|
@@ -65,6 +69,23 @@ const isSafeName = (name: string): boolean => SAFE_LANE_NAME.test(name) && !name
|
|
|
65
69
|
const lanesWith = (lanes: Record<string, string>, status: AgentStatus): string[] =>
|
|
66
70
|
Object.entries(lanes).flatMap(([name, laneStatus]) => (laneStatus === status && isSafeName(name) ? [name] : []))
|
|
67
71
|
|
|
72
|
+
const MODEL_TEXT_LIMIT = 60
|
|
73
|
+
|
|
74
|
+
/** A board cell for the log: printable characters only, so a column cannot break a log line. */
|
|
75
|
+
const printable = (column: string): string =>
|
|
76
|
+
column
|
|
77
|
+
.replace(/[^\p{L}\p{N}._/ ,+|-]/gu, '?')
|
|
78
|
+
.trim()
|
|
79
|
+
.slice(0, MODEL_TEXT_LIMIT)
|
|
80
|
+
|
|
81
|
+
const modelsOf = (record: ResumeRecord, lanes: readonly string[]): Record<string, string> =>
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
lanes.flatMap((name) => {
|
|
84
|
+
const column = record.laneModels?.[name]
|
|
85
|
+
return column === undefined ? [] : [[name, printable(column)]]
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
|
|
68
89
|
const skip = (reason: ResumeSkip): ResumeDecision => ({ kind: 'none', reason })
|
|
69
90
|
|
|
70
91
|
/** The reason the record itself gives against a restart, checked before what the system says now. */
|
|
@@ -90,12 +111,15 @@ export const resumeDecision = ({ record, now, watcherRunning, boardExists }: Res
|
|
|
90
111
|
const board = boardInside(record.root, record.board)
|
|
91
112
|
if (board === null) return skip(RESUME_SKIP.unsafeBoard)
|
|
92
113
|
if (!boardExists) return skip(RESUME_SKIP.boardMissing)
|
|
114
|
+
const interrupted = lanesWith(record.lanes, AGENT_STATUS.working)
|
|
93
115
|
return {
|
|
94
116
|
kind: 'resume',
|
|
95
117
|
root: record.root,
|
|
96
118
|
board,
|
|
97
119
|
coordinator: record.coordinator,
|
|
98
|
-
|
|
120
|
+
coordinatorIdentity: record.coordinatorIdentity ?? null,
|
|
121
|
+
laneModels: modelsOf(record, interrupted),
|
|
122
|
+
interrupted,
|
|
99
123
|
blocked: lanesWith(record.lanes, AGENT_STATUS.blocked),
|
|
100
124
|
unsafe: Object.keys(record.lanes).filter((name) => !isSafeName(name)),
|
|
101
125
|
}
|
package/src/resume/keeper.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { errorText } from '../plans/error-text'
|
|
2
|
-
import type { LaneSnapshot } from '../watch/lane-watch'
|
|
3
|
-
import {
|
|
2
|
+
import type { HerdrAgent, LaneSnapshot } from '../watch/lane-watch'
|
|
3
|
+
import {
|
|
4
|
+
type CoordinatorIdentity,
|
|
5
|
+
followedRecord,
|
|
6
|
+
type Observed,
|
|
7
|
+
type RecordStart,
|
|
8
|
+
type ResumeRecord,
|
|
9
|
+
runningRecord,
|
|
10
|
+
stoppedRecord,
|
|
11
|
+
} from './record'
|
|
12
|
+
import { sessionValueOf } from './targets'
|
|
4
13
|
|
|
5
14
|
/** The one record file of a repository; `read` gives null for a missing or damaged one. */
|
|
6
15
|
export type RecordStore = {
|
|
@@ -15,13 +24,31 @@ export type KeeperPorts = {
|
|
|
15
24
|
|
|
16
25
|
/** What the watcher calls: `follow` at every herdr poll, `stop` once on a clean end. */
|
|
17
26
|
export type RecordKeeper = {
|
|
18
|
-
follow: (lanes: LaneSnapshot, now: number) => void
|
|
27
|
+
follow: (lanes: LaneSnapshot, now: number, observed?: Observed) => void
|
|
19
28
|
stop: (now: number) => void
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export const laneStatusesOf = (snapshot: LaneSnapshot): Record<string, string> =>
|
|
23
32
|
Object.fromEntries(Object.entries(snapshot).map(([name, lane]) => [name, lane.status]))
|
|
24
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What identifies the coordinator without its name: the agent herdr lists under `name`, else the unnamed agent in the
|
|
36
|
+
* watcher's own pane (as `coordinatorTargetOf` finds it). Null when neither is listed.
|
|
37
|
+
*/
|
|
38
|
+
export const coordinatorIdentityOf = (
|
|
39
|
+
agents: readonly HerdrAgent[],
|
|
40
|
+
name: string,
|
|
41
|
+
ownPane: string | undefined,
|
|
42
|
+
): CoordinatorIdentity | null => {
|
|
43
|
+
const agent =
|
|
44
|
+
agents.find((each) => each.name === name) ?? agents.find((each) => each.pane_id === ownPane && !each.name)
|
|
45
|
+
return agent ? { pane: agent.pane_id, session: sessionValueOf(agent.agent_session) } : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Lane → the model column of its board row, from the watcher's last model readings. */
|
|
49
|
+
export const laneModelColumnsOf = (readings: Record<string, { column: string }>): Record<string, string> =>
|
|
50
|
+
Object.fromEntries(Object.entries(readings).map(([lane, { column }]) => [lane, column]))
|
|
51
|
+
|
|
25
52
|
/**
|
|
26
53
|
* Keeps the resume record of the watcher in `start.pid` beside its loop: running at the start, saved when a lane status
|
|
27
54
|
* changes or `RESUME_REFRESH_MS` is up, stamped as stopped at a clean end unless a successor holds the file by then (a
|
|
@@ -48,8 +75,8 @@ export const keepRecord = (start: RecordStart, now: number, ports: KeeperPorts):
|
|
|
48
75
|
let current = runningRecord(start, now)
|
|
49
76
|
let unsaved = !attempt(() => ports.store.write(current))
|
|
50
77
|
return {
|
|
51
|
-
follow: (lanes, at) => {
|
|
52
|
-
const next = followedRecord(current, laneStatusesOf(lanes), at)
|
|
78
|
+
follow: (lanes, at, observed) => {
|
|
79
|
+
const next = followedRecord(current, laneStatusesOf(lanes), at, observed)
|
|
53
80
|
if (next === current && !unsaved) return
|
|
54
81
|
current = next
|
|
55
82
|
unsaved = !attempt(() => ports.store.write(next))
|
package/src/resume/ports.ts
CHANGED
|
@@ -88,6 +88,9 @@ export const systemResumePorts = (home: string = homedir()): ResumePorts => {
|
|
|
88
88
|
return { language: responseLanguage, worktreeDirectory }
|
|
89
89
|
},
|
|
90
90
|
agentPanes: () => resumablePanesOf(herdr(['agent', 'list']), herdr(['pane', 'list'])),
|
|
91
|
+
rename: (pane, name) => {
|
|
92
|
+
herdr(['agent', 'rename', pane, name])
|
|
93
|
+
},
|
|
91
94
|
send: herdrSendPorts(),
|
|
92
95
|
sleep: (ms) => Bun.sleep(ms),
|
|
93
96
|
log,
|
package/src/resume/record.ts
CHANGED
|
@@ -9,6 +9,9 @@ export const RESUME_RECORD_VERSION = 1
|
|
|
9
9
|
/** Below `~/.jackherd/`: the user's own, outside every repository an agent writes to. */
|
|
10
10
|
const RESUME_DIRECTORY = 'resume'
|
|
11
11
|
|
|
12
|
+
/** The herdr pane and the agent session id of the coordinator; either may be unknown. */
|
|
13
|
+
export type CoordinatorIdentity = { pane: string | null; session: string | null }
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* What the watcher keeps about one repository so `jh resume` can tell a crash from a stop: a record without `stoppedAt`
|
|
14
17
|
* was left by a watcher that never ended cleanly. `lanes` holds each lane's last `agent_status`, `resumedAt` the one
|
|
@@ -21,10 +24,18 @@ export type ResumeRecord = {
|
|
|
21
24
|
board: string
|
|
22
25
|
/** The coordinator's herdr name. */
|
|
23
26
|
coordinator: string
|
|
27
|
+
/**
|
|
28
|
+
* What finds the coordinator when herdr restores its pane without the name (`herdr agent rename` does not survive a
|
|
29
|
+
* restore, the session id and the pane id do). Absent in records of older versions and until the watcher has seen
|
|
30
|
+
* the coordinator.
|
|
31
|
+
*/
|
|
32
|
+
coordinatorIdentity?: CoordinatorIdentity
|
|
24
33
|
pid: number
|
|
25
34
|
startedAt: number
|
|
26
35
|
savedAt: number
|
|
27
36
|
lanes: Record<string, string>
|
|
37
|
+
/** Lane → the model column of its board row as the watcher last read it; absent in older records. */
|
|
38
|
+
laneModels?: Record<string, string>
|
|
28
39
|
stoppedAt: number | null
|
|
29
40
|
resumedAt: number | null
|
|
30
41
|
}
|
|
@@ -41,6 +52,11 @@ const isAbsolutePath = (value: unknown): boolean => typeof value === 'string' &&
|
|
|
41
52
|
const isLaneStatuses = (value: unknown): boolean =>
|
|
42
53
|
isRecord(value) && Object.values(value).every((status) => typeof status === 'string')
|
|
43
54
|
|
|
55
|
+
const isStringOrNull = (value: unknown): boolean => value === null || typeof value === 'string'
|
|
56
|
+
|
|
57
|
+
const isCoordinatorIdentity = (value: unknown): boolean =>
|
|
58
|
+
isRecord(value) && isStringOrNull(value.pane) && isStringOrNull(value.session)
|
|
59
|
+
|
|
44
60
|
export const isResumeRecord = (value: unknown): value is ResumeRecord =>
|
|
45
61
|
isRecord(value) &&
|
|
46
62
|
value.version === RESUME_RECORD_VERSION &&
|
|
@@ -52,6 +68,8 @@ export const isResumeRecord = (value: unknown): value is ResumeRecord =>
|
|
|
52
68
|
isTime(value.startedAt) &&
|
|
53
69
|
isTime(value.savedAt) &&
|
|
54
70
|
isLaneStatuses(value.lanes) &&
|
|
71
|
+
(value.coordinatorIdentity === undefined || isCoordinatorIdentity(value.coordinatorIdentity)) &&
|
|
72
|
+
(value.laneModels === undefined || isLaneStatuses(value.laneModels)) &&
|
|
55
73
|
'stoppedAt' in value &&
|
|
56
74
|
isTimeOrNull(value.stoppedAt) &&
|
|
57
75
|
'resumedAt' in value &&
|
|
@@ -94,6 +112,26 @@ const sameStatuses = (left: Record<string, string>, right: Record<string, string
|
|
|
94
112
|
export const withLanes = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
|
|
95
113
|
sameStatuses(record.lanes, lanes) ? record : { ...record, lanes: { ...lanes }, savedAt: now }
|
|
96
114
|
|
|
115
|
+
const sameIdentity = (left: CoordinatorIdentity | undefined, right: CoordinatorIdentity): boolean =>
|
|
116
|
+
left?.pane === right.pane && left?.session === right.session
|
|
117
|
+
|
|
118
|
+
/** The record with the coordinator's identity; the same object when it is unchanged or unknown (null keeps the stored one). */
|
|
119
|
+
export const withIdentity = (record: ResumeRecord, identity: CoordinatorIdentity | null, now: number): ResumeRecord =>
|
|
120
|
+
identity === null ||
|
|
121
|
+
(identity.pane === null && identity.session === null) ||
|
|
122
|
+
sameIdentity(record.coordinatorIdentity, identity)
|
|
123
|
+
? record
|
|
124
|
+
: { ...record, coordinatorIdentity: identity, savedAt: now }
|
|
125
|
+
|
|
126
|
+
/** The record with the lanes' model columns; the same object when unchanged, and a lane without a column keeps its stored one. */
|
|
127
|
+
export const withLaneModels = (record: ResumeRecord, laneModels: Record<string, string>, now: number): ResumeRecord => {
|
|
128
|
+
const merged = { ...record.laneModels, ...laneModels }
|
|
129
|
+
return sameStatuses(record.laneModels ?? {}, merged) ? record : { ...record, laneModels: merged, savedAt: now }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What the watcher saw at one poll besides the lane statuses; both parts may be unknown. */
|
|
133
|
+
export type Observed = { coordinator?: CoordinatorIdentity | null; laneModels?: Record<string, string> }
|
|
134
|
+
|
|
97
135
|
/**
|
|
98
136
|
* How often the watcher saves its record although no lane changed: once an hour, so a watcher that rests longer than
|
|
99
137
|
* `RESUME_MAX_AGE_MS` of `decision.ts` without a change still leaves a record young enough to resume after a crash.
|
|
@@ -105,8 +143,20 @@ export const refreshedRecord = (record: ResumeRecord, now: number): ResumeRecord
|
|
|
105
143
|
now - record.savedAt < RESUME_REFRESH_MS ? record : { ...record, savedAt: now }
|
|
106
144
|
|
|
107
145
|
/** The record after one herdr poll: new statuses, else a new save time once due; the same object when neither. */
|
|
108
|
-
export const followedRecord = (
|
|
109
|
-
|
|
146
|
+
export const followedRecord = (
|
|
147
|
+
record: ResumeRecord,
|
|
148
|
+
lanes: Record<string, string>,
|
|
149
|
+
now: number,
|
|
150
|
+
observed: Observed = {},
|
|
151
|
+
): ResumeRecord =>
|
|
152
|
+
refreshedRecord(
|
|
153
|
+
withLaneModels(
|
|
154
|
+
withIdentity(withLanes(record, lanes, now), observed.coordinator ?? null, now),
|
|
155
|
+
observed.laneModels ?? {},
|
|
156
|
+
now,
|
|
157
|
+
),
|
|
158
|
+
now,
|
|
159
|
+
)
|
|
110
160
|
|
|
111
161
|
/** The record stamped as stopped cleanly; null when it names another process, whose record a successor keeps. */
|
|
112
162
|
export const stoppedRecord = (record: ResumeRecord, ownPid: number, now: number): ResumeRecord | null =>
|
package/src/resume/targets.ts
CHANGED
|
@@ -2,7 +2,15 @@ import { isRecord } from '../shared/is-record'
|
|
|
2
2
|
import { parseJsonOrNull } from '../shared/parse-json'
|
|
3
3
|
|
|
4
4
|
/** One pane an agent runs in, from herdr's lists; `name` is null while herdr names none. */
|
|
5
|
-
export type AgentPane = { pane: string; name: string | null; cwd: string | null }
|
|
5
|
+
export type AgentPane = { pane: string; name: string | null; cwd: string | null; session: string | null }
|
|
6
|
+
|
|
7
|
+
/** What finds an agent whose name herdr lost: its session id (preferred) and the pane it ran in. */
|
|
8
|
+
export type AgentIdentity = { pane: string | null; session: string | null }
|
|
9
|
+
|
|
10
|
+
/** How a target was found: by the name, by the session id or pane id of the record, or as the one unnamed pane in the directory. */
|
|
11
|
+
export const TARGET_VIA = { name: 'name', session: 'session', pane: 'pane', directory: 'directory' } as const
|
|
12
|
+
|
|
13
|
+
export type TargetVia = (typeof TARGET_VIA)[keyof typeof TARGET_VIA]
|
|
6
14
|
|
|
7
15
|
export const TARGET_MISS = { missing: 'missing', ambiguous: 'ambiguous', elsewhere: 'elsewhere' } as const
|
|
8
16
|
|
|
@@ -12,7 +20,7 @@ export type TargetMiss = (typeof TARGET_MISS)[keyof typeof TARGET_MISS]
|
|
|
12
20
|
export type TargetMissed = { kind: 'none'; reason: TargetMiss; panes: string[] }
|
|
13
21
|
|
|
14
22
|
/** Where a message goes: the agent's name or its pane id, `byName` when herdr named it. */
|
|
15
|
-
export type Target = { kind: 'found'; target: string; byName: boolean } | TargetMissed
|
|
23
|
+
export type Target = { kind: 'found'; target: string; byName: boolean; via: TargetVia } | TargetMissed
|
|
16
24
|
|
|
17
25
|
/** The one unnamed pane found in the directory so far, and in how many polls in a row. */
|
|
18
26
|
export type Streak = { pane: string; polls: number } | null
|
|
@@ -34,6 +42,12 @@ const entriesOf = (json: string, key: 'agents' | 'panes'): Record<string, unknow
|
|
|
34
42
|
|
|
35
43
|
const stringOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null)
|
|
36
44
|
|
|
45
|
+
/** The session id of herdr's `agent_session` (`{ value }`); null for none. */
|
|
46
|
+
export const sessionValueOf = (agentSession: unknown): string | null => {
|
|
47
|
+
const value = isRecord(agentSession) ? agentSession.value : undefined
|
|
48
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
/**
|
|
38
52
|
* The agent panes of `herdr agent list` and `herdr pane list`, each once; none from an answer that cannot be read.
|
|
39
53
|
* Right after a restart the pane list names no agent for a few seconds while the agent list already does, and the
|
|
@@ -42,33 +56,69 @@ const stringOrNull = (value: unknown): string | null => (typeof value === 'strin
|
|
|
42
56
|
*/
|
|
43
57
|
export const resumablePanesOf = (agentList: string, paneList: string): AgentPane[] => {
|
|
44
58
|
const found = new Map<string, AgentPane>()
|
|
45
|
-
for (const { pane_id, name, cwd } of entriesOf(agentList, 'agents'))
|
|
59
|
+
for (const { pane_id, name, cwd, agent_session } of entriesOf(agentList, 'agents'))
|
|
46
60
|
if (typeof pane_id === 'string')
|
|
47
|
-
found.set(pane_id, {
|
|
61
|
+
found.set(pane_id, {
|
|
62
|
+
pane: pane_id,
|
|
63
|
+
name: stringOrNull(name),
|
|
64
|
+
cwd: stringOrNull(cwd),
|
|
65
|
+
session: sessionValueOf(agent_session),
|
|
66
|
+
})
|
|
48
67
|
for (const { pane_id, agent, cwd } of entriesOf(paneList, 'panes')) {
|
|
49
68
|
if (typeof pane_id !== 'string' || (typeof agent !== 'string' && !found.has(pane_id))) continue
|
|
50
69
|
const known = found.get(pane_id)
|
|
51
|
-
found.set(pane_id, {
|
|
70
|
+
found.set(pane_id, {
|
|
71
|
+
pane: pane_id,
|
|
72
|
+
name: known?.name ?? null,
|
|
73
|
+
cwd: known?.cwd ?? stringOrNull(cwd),
|
|
74
|
+
session: known?.session ?? null,
|
|
75
|
+
})
|
|
52
76
|
}
|
|
53
77
|
return [...found.values()]
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
const missed = (reason: TargetMiss, panes: string[]): TargetMissed => ({ kind: 'none', reason, panes })
|
|
57
81
|
|
|
82
|
+
const isUnnamedIn = (pane: AgentPane, directory: string): boolean => pane.name === null && pane.cwd === directory
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The unnamed pane in `directory` that the record's identity points at: the one with its session id, else (without a
|
|
86
|
+
* session match) the one with its pane id. A session id names one conversation, so it needs no second look; a pane id
|
|
87
|
+
* may have passed to another agent, so the caller lets it stay alone for a few polls (`steadyTarget`).
|
|
88
|
+
*/
|
|
89
|
+
const identifiedTarget = (panes: readonly AgentPane[], identity: AgentIdentity, directory: string): Target | null => {
|
|
90
|
+
const bySession = identity.session === null ? undefined : panes.find((pane) => pane.session === identity.session)
|
|
91
|
+
if (bySession && isUnnamedIn(bySession, directory))
|
|
92
|
+
return { kind: 'found', target: bySession.pane, byName: false, via: TARGET_VIA.session }
|
|
93
|
+
const byPane = identity.pane === null ? undefined : panes.find((pane) => pane.pane === identity.pane)
|
|
94
|
+
return byPane && !bySession && isUnnamedIn(byPane, directory)
|
|
95
|
+
? { kind: 'found', target: byPane.pane, byName: false, via: TARGET_VIA.pane }
|
|
96
|
+
: null
|
|
97
|
+
}
|
|
98
|
+
|
|
58
99
|
/**
|
|
59
100
|
* The agent to send to: the one named `name` when it runs in `directory` (the main checkout for the coordinator, the
|
|
60
101
|
* worktree for a lane) or herdr names no directory; the name in another directory belongs to another repository's
|
|
61
102
|
* agent. Without the name, the one unnamed agent pane in `directory`: an agent with another name there is another
|
|
62
|
-
* agent, a helper of the lane for one, and two unnamed panes are never guessed between
|
|
103
|
+
* agent, a helper of the lane for one, and two unnamed panes are never guessed between, unless the record's
|
|
104
|
+
* `identity` (session id, pane id) points at one of them.
|
|
63
105
|
*/
|
|
64
|
-
export const targetFor = (
|
|
106
|
+
export const targetFor = (
|
|
107
|
+
panes: readonly AgentPane[],
|
|
108
|
+
name: string,
|
|
109
|
+
directory: string,
|
|
110
|
+
identity?: AgentIdentity,
|
|
111
|
+
): Target => {
|
|
65
112
|
const named = panes.find((pane) => pane.name === name)
|
|
66
113
|
if (named)
|
|
67
114
|
return named.cwd === null || named.cwd === directory
|
|
68
|
-
? { kind: 'found', target: name, byName: true }
|
|
115
|
+
? { kind: 'found', target: name, byName: true, via: TARGET_VIA.name }
|
|
69
116
|
: missed(TARGET_MISS.elsewhere, [named.pane])
|
|
117
|
+
const identified = identity ? identifiedTarget(panes, identity, directory) : null
|
|
118
|
+
if (identified) return identified
|
|
70
119
|
const candidates = panes.filter((pane) => pane.name === null && pane.cwd === directory).map(({ pane }) => pane)
|
|
71
|
-
if (candidates.length === 1)
|
|
120
|
+
if (candidates.length === 1)
|
|
121
|
+
return { kind: 'found', target: candidates[0] as string, byName: false, via: TARGET_VIA.directory }
|
|
72
122
|
return missed(candidates.length === 0 ? TARGET_MISS.missing : TARGET_MISS.ambiguous, candidates)
|
|
73
123
|
}
|
|
74
124
|
|
|
@@ -77,7 +127,7 @@ export const targetFor = (panes: readonly AgentPane[], name: string, directory:
|
|
|
77
127
|
* alone for `STEADY_POLLS` polls in a row; until then it counts as missing. Returns the streak for the next poll.
|
|
78
128
|
*/
|
|
79
129
|
export const steadyTarget = (target: Target, streak: Streak): { target: Target; streak: Streak } => {
|
|
80
|
-
if (target.kind === 'none' || target.byName) return { target, streak: null }
|
|
130
|
+
if (target.kind === 'none' || target.byName || target.via === TARGET_VIA.session) return { target, streak: null }
|
|
81
131
|
const polls = streak?.pane === target.target ? streak.polls + 1 : 1
|
|
82
132
|
const next = { pane: target.target, polls }
|
|
83
133
|
return { target: polls >= STEADY_POLLS ? target : missed(TARGET_MISS.missing, []), streak: next }
|
package/src/resume/texts.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
|
3
3
|
/** The fixed words `jh resume` sends; nothing else from outside reaches a session but checked lane names and the board. */
|
|
4
4
|
export type ResumeTexts = {
|
|
5
5
|
/** One line, no break: herdr would send a break as an enter and split the command. */
|
|
6
|
-
coordinatorLine: (
|
|
6
|
+
coordinatorLine: (
|
|
7
|
+
board: string,
|
|
8
|
+
interrupted: readonly string[],
|
|
9
|
+
blocked: readonly string[],
|
|
10
|
+
modelCheck?: readonly string[],
|
|
11
|
+
) => string
|
|
7
12
|
/** One line, no break: the hand-over script refuses one. The first input of the chat after a clear. */
|
|
8
13
|
clearLine: (board: string) => string
|
|
9
14
|
laneText: string
|
|
@@ -11,18 +16,29 @@ export type ResumeTexts = {
|
|
|
11
16
|
|
|
12
17
|
const listed = (lanes: readonly string[], none: string): string => (lanes.length > 0 ? lanes.join(', ') : none)
|
|
13
18
|
|
|
19
|
+
const withModelCheck = (line: string, note: string, lanes: readonly string[]): string =>
|
|
20
|
+
lanes.length > 0 ? `${line} ${note}: ${lanes.join(', ')}.` : line
|
|
21
|
+
|
|
14
22
|
const TEXTS: Record<PlanLanguage, ResumeTexts> = {
|
|
15
23
|
[PLAN_LANGUAGE.de]: {
|
|
16
|
-
coordinatorLine: (board, interrupted, blocked) =>
|
|
17
|
-
|
|
24
|
+
coordinatorLine: (board, interrupted, blocked, modelCheck = []) =>
|
|
25
|
+
withModelCheck(
|
|
26
|
+
`/orchestrate ${board} Neustart nach Absturz: Watcher starten (§ 4), Wecker mit den Terminen abgleichen; fortgesetzt: ${listed(interrupted, 'keine')}; vor dem Absturz blockiert: ${listed(blocked, 'keine')}.`,
|
|
27
|
+
'Diese Spuren kamen ohne --model zurück, Modell gegen die Board-Zeile prüfen und nach lane-session.md umstellen',
|
|
28
|
+
modelCheck,
|
|
29
|
+
),
|
|
18
30
|
clearLine: (board) =>
|
|
19
31
|
`/orchestrate ${board} Aufgeräumt: zuerst jh resume --coordinator --board ${board}, dann Watcher starten (§ 4).`,
|
|
20
32
|
laneText:
|
|
21
33
|
'Der Rechner ist abgestürzt und herdr wurde neu gestartet. Dein letzter Befehl wurde unterbrochen: prüfe, wie weit er kam, und setze ihn fort.',
|
|
22
34
|
},
|
|
23
35
|
[PLAN_LANGUAGE.en]: {
|
|
24
|
-
coordinatorLine: (board, interrupted, blocked) =>
|
|
25
|
-
|
|
36
|
+
coordinatorLine: (board, interrupted, blocked, modelCheck = []) =>
|
|
37
|
+
withModelCheck(
|
|
38
|
+
`/orchestrate ${board} Restart after a crash: start the watcher (§ 4), match the timers against the appointments; resumed: ${listed(interrupted, 'none')}; blocked before the crash: ${listed(blocked, 'none')}.`,
|
|
39
|
+
'These lanes came back without --model, check the model against the board row and switch it as lane-session.md says',
|
|
40
|
+
modelCheck,
|
|
41
|
+
),
|
|
26
42
|
clearLine: (board) =>
|
|
27
43
|
`/orchestrate ${board} Cleared: first jh resume --coordinator --board ${board}, then start the watcher (§ 4).`,
|
|
28
44
|
laneText:
|
package/src/watch/command.ts
CHANGED
|
@@ -52,8 +52,9 @@ import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '.
|
|
|
52
52
|
import { readRunningSubagents, subagentSourceOf } from '../plans/subagents-read'
|
|
53
53
|
import { boardTexts } from '../plans/texts'
|
|
54
54
|
import { readWaitingRecords, waitingItemsOf } from '../plans/waiting-records'
|
|
55
|
-
import { keepRecord } from '../resume/keeper'
|
|
55
|
+
import { coordinatorIdentityOf, keepRecord, laneModelColumnsOf } from '../resume/keeper'
|
|
56
56
|
import { systemRecordStore } from '../resume/ports'
|
|
57
|
+
import type { Observed } from '../resume/record'
|
|
57
58
|
import { defaultUpdateCheck } from '../sync/if-changed'
|
|
58
59
|
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
59
60
|
import { herdrLaneStatusSource } from '../terminal/herdr'
|
|
@@ -1597,7 +1598,15 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
|
1597
1598
|
Date.now(),
|
|
1598
1599
|
{ store: systemRecordStore(watch.root), log: console.log },
|
|
1599
1600
|
)
|
|
1600
|
-
|
|
1601
|
+
const observed = (): Observed => ({
|
|
1602
|
+
coordinator: coordinatorIdentityOf(
|
|
1603
|
+
watching.agents,
|
|
1604
|
+
coordinatorNameFor(watch, watching.agents),
|
|
1605
|
+
process.env.HERDR_PANE_ID,
|
|
1606
|
+
),
|
|
1607
|
+
laneModels: laneModelColumnsOf(watching.laneModelReadings),
|
|
1608
|
+
})
|
|
1609
|
+
resumeRecord.follow(armed, Date.now(), observed())
|
|
1601
1610
|
process.on('SIGTERM', () => {
|
|
1602
1611
|
resumeRecord.stop(Date.now())
|
|
1603
1612
|
saveState(watch, watching)
|
|
@@ -1629,7 +1638,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
|
1629
1638
|
while (await pollOnce(watch, watching)) {
|
|
1630
1639
|
await Bun.sleep(POLL_MS)
|
|
1631
1640
|
pollLanes(watch, watching)
|
|
1632
|
-
resumeRecord.follow(watching.current, Date.now())
|
|
1641
|
+
resumeRecord.follow(watching.current, Date.now(), observed())
|
|
1633
1642
|
await announceUpdate?.()
|
|
1634
1643
|
if (endedByKitUpdate(kit, effects)) return true
|
|
1635
1644
|
}
|
package/src/watch/lane-watch.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { AGENT_STATUS } from '../terminal/types'
|
|
2
2
|
|
|
3
|
-
export type HerdrAgent = {
|
|
3
|
+
export type HerdrAgent = {
|
|
4
|
+
name?: string
|
|
5
|
+
pane_id: string
|
|
6
|
+
agent_status: string
|
|
7
|
+
agent_session?: unknown
|
|
8
|
+
state_change_seq?: number
|
|
9
|
+
}
|
|
4
10
|
|
|
5
11
|
/** The state a report gives a lane whose agent left the herdr listing. */
|
|
6
12
|
export const LANE_GONE = 'no longer listed'
|