jackherd 1.12.4 → 1.12.6
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 +16 -16
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +17 -17
- package/package.json +1 -1
- package/src/plan-format/clock.ts +7 -1
- package/src/plans/coordinator-row.ts +95 -0
- package/src/plans/herd-state.ts +9 -4
- package/src/plans/layout.ts +29 -1
- package/src/plans/snapshot.ts +31 -2
- package/src/plans/view.ts +1 -0
- package/src/watch/command.ts +26 -4
- package/src/watch/lane-report.ts +16 -7
- package/src/watch/lane-stalled.ts +59 -0
- package/src/watch/watch-events.ts +7 -0
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.12.6
|
|
6
|
+
|
|
7
|
+
- **The coordinator has a line in the herd.** While every lane rests and the coordinator works (a diagnosis, a review, a release), the herd no longer looks like a standstill: a line over the lanes, under "Your turn" and "Sources", set like the head of a block of one line, reads `● Jack · working · <task> · since 12:54`: the coordinator's state, the task it reported with `jh task start` from the main checkout and since when, its context fill at the right edge; at rest `○ Jack · idle`, without the last task. The name stands in the column of the block names. While the coordinator works, its task shimmers like the running task of a folded lane (and stands still in teal with `plans.shimmer: false`). A long task is cut with `…`, the time and the fill stay. The line is no lane and stays when "Lanes" is folded. That task no longer shows as a separate `now:` line. A lane's own herd shows no coordinator line. The orchestrate skill tells the coordinator to report each step of its own that way, and `jh task done` at its end.
|
|
8
|
+
|
|
9
|
+
## 1.12.5
|
|
10
|
+
|
|
11
|
+
- **A lane that stands still is news.** Since 1.12.4 an unchanged heartbeat stays silent, so a working lane whose phase, ticks and subagents do not move would go unnoticed. `jh watch --stream` now reports such a lane once, after three heartbeat intervals without a move (45 minutes at the default 15), as its own line `lane <name> stalled <n> min`, and stays silent until the lane moves again. The orchestrate skill tells the coordinator to read that lane, instead of comparing counts across heartbeats.
|
|
12
|
+
|
|
5
13
|
## 1.12.4
|
|
6
14
|
|
|
7
15
|
- **A quiet stream when nothing changed.** `jh watch --stream` sends a heartbeat only when its state (lanes, their progress, what waits on you, the main checkout) changed since the last one it sent, so a coordinator's chat no longer fills with identical heartbeats over hours. The events a heartbeat brings (parallel tasks, a lane without a task signal, an unlinked Artifact) come as before.
|
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.12.
|
|
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.12.6/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)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.6/assets/icons/space.svg">
|
|
7
7
|
|
|
8
8
|
# Jack herds your coding agents.
|
|
9
9
|
|
|
@@ -15,25 +15,25 @@ 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.12.
|
|
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.12.6/assets/icons/space.svg">
|
|
19
19
|
|
|
20
20
|
<p>
|
|
21
|
-
<img src="https://unpkg.com/jackherd@1.12.
|
|
21
|
+
<img src="https://unpkg.com/jackherd@1.12.6/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.12.
|
|
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.12.6/assets/icons/space.svg">
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
-
<p><img src="https://unpkg.com/jackherd@1.12.
|
|
26
|
+
<p><img src="https://unpkg.com/jackherd@1.12.6/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.12.
|
|
28
|
+
<p><img src="https://unpkg.com/jackherd@1.12.6/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
|
|
29
29
|
|
|
30
|
-
<p><img src="https://unpkg.com/jackherd@1.12.
|
|
30
|
+
<p><img src="https://unpkg.com/jackherd@1.12.6/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
|
|
31
31
|
|
|
32
|
-
<p><img src="https://unpkg.com/jackherd@1.12.
|
|
32
|
+
<p><img src="https://unpkg.com/jackherd@1.12.6/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
|
|
33
33
|
|
|
34
|
-
<p><img src="https://unpkg.com/jackherd@1.12.
|
|
34
|
+
<p><img src="https://unpkg.com/jackherd@1.12.6/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.</p>
|
|
35
35
|
|
|
36
|
-
<p><img src="https://unpkg.com/jackherd@1.12.
|
|
36
|
+
<p><img src="https://unpkg.com/jackherd@1.12.6/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.6/assets/icons/space.svg"></p>
|
|
37
37
|
|
|
38
38
|
## Quickstart
|
|
39
39
|
|
|
@@ -69,22 +69,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
|
|
|
69
69
|
/jh-orchestrate docs/plans/board-<name>/board.md
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
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. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.
|
|
72
|
+
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. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.6/assets/icons/space.svg">
|
|
73
73
|
|
|
74
74
|
## Follow anything outside GitHub
|
|
75
75
|
|
|
76
76
|
Whatever you check by hand, Jack can watch: tickets, error logs, status pages, deploys. One `/jh-follow`, one yes, and every change comes to you like this:
|
|
77
77
|
|
|
78
|
-
<img src="https://unpkg.com/jackherd@1.12.
|
|
78
|
+
<img src="https://unpkg.com/jackherd@1.12.6/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.">
|
|
79
79
|
|
|
80
|
-
The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.
|
|
80
|
+
The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.6/assets/icons/space.svg">
|
|
81
81
|
|
|
82
82
|
## In detail
|
|
83
83
|
|
|
84
84
|
<details>
|
|
85
85
|
<summary><b>What Jack does on his own, and what waits for you</b></summary>
|
|
86
86
|
|
|
87
|
-
<img src="https://unpkg.com/jackherd@1.12.
|
|
87
|
+
<img src="https://unpkg.com/jackherd@1.12.6/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.">
|
|
88
88
|
|
|
89
89
|
| On his own | After your approval | Never |
|
|
90
90
|
| --- | --- | --- |
|
|
@@ -117,7 +117,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
|
|
|
117
117
|
|
|
118
118
|
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.
|
|
119
119
|
|
|
120
|
-
<img src="https://unpkg.com/jackherd@1.12.
|
|
120
|
+
<img src="https://unpkg.com/jackherd@1.12.6/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
|
|
121
121
|
|
|
122
122
|
| Skill | Does |
|
|
123
123
|
| --- | --- |
|
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.12.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.6}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -99,7 +99,7 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
|
|
|
99
99
|
2. **Worktree workspace** from `<base>`: `origin/{{kit.baseBranch}}` when, after
|
|
100
100
|
`git fetch origin {{kit.baseBranch}}`, `git cat-file -e origin/{{kit.baseBranch}}:<plan>` succeeds
|
|
101
101
|
for the lane's plan, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
|
|
102
|
-
the PR, so a lane
|
|
102
|
+
the PR, so a later lane gets the right base:
|
|
103
103
|
```bash
|
|
104
104
|
herdr worktree create --cwd <main-checkout> --branch <branch> --base <base> \
|
|
105
105
|
--path <main-checkout>/{{kit.worktreeDirectory}}/<lane> --label "<lane> :<port>" --no-focus
|
|
@@ -115,8 +115,8 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
|
|
|
115
115
|
`## {{kit.heading.lanes}}` table has the lane's row: lane, port, plan, phases, starts after,
|
|
116
116
|
model. The phases cell starts with the phase id (`P10c …`), also for a sub-phase, and the plan
|
|
117
117
|
column links the plan once it exists, in the PR that adds the plan, in the row of every lane that
|
|
118
|
-
implements one of its phases.
|
|
119
|
-
|
|
118
|
+
implements one of its phases. Check that this prints it, else
|
|
119
|
+
write it first:
|
|
120
120
|
```bash
|
|
121
121
|
grep -F "| \`<lane>\`" <board>
|
|
122
122
|
```
|
|
@@ -147,8 +147,8 @@ at it before the PR question (§ 5). The command is the project's dev server on
|
|
|
147
147
|
(`PORT=<port> {{kit.command.dev}}`, unless the project supplement names another).
|
|
148
148
|
|
|
149
149
|
Never in the lane's shell: the agent would block on it. Split a pane inside the lane's workspace
|
|
150
|
-
and run it there, **beside the agent** (`--direction right`), so the agent
|
|
151
|
-
|
|
150
|
+
and run it there, **beside the agent** (`--direction right`), so the agent keeps
|
|
151
|
+
its height:
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
154
|
herdr pane split <lane-pane> --direction right --no-focus
|
|
@@ -192,16 +192,14 @@ watcher finds you by the default name of § 0, or by `--coordinator <name>`. In
|
|
|
192
192
|
arrives as one message, and the watcher keeps running.
|
|
193
193
|
|
|
194
194
|
The heartbeat (default 15 minutes, due after that long without a report) is one line,
|
|
195
|
-
`=== heartbeat <time> · <lane> <status> · …`, sent only on a change. Each working lane carries its current phase
|
|
196
|
-
(`<lane> working: P12 7/12`)
|
|
197
|
-
|
|
198
|
-
one to read yourself. `lane added: <name>` and `lane removed: <name>` mark a change of the found lanes.
|
|
195
|
+
`=== heartbeat <time> · <lane> <status> · …`, sent only on a change. Each working lane carries its current phase, its ticks
|
|
196
|
+
and the subagents in its worktree (`<lane> working: P12 7/12, 3 subagents`). A working lane whose line stands
|
|
197
|
+
still three heartbeat intervals comes once as `lane <name> stalled <n> min`. `lane added: <name>` and `lane removed: <name>` mark a change.
|
|
199
198
|
An agent is watched from the state it first shows; one that exits fires as `no longer listed`.
|
|
200
199
|
Deploy runs are reported only when the config sets `watch.deploy`.
|
|
201
200
|
|
|
202
201
|
Something outside GitHub (a ticket, an issue, an error, a status page) the watcher reports only as
|
|
203
|
-
a configured source (`watch.sources`). When the user asks you to follow such an item
|
|
204
|
-
"tell me when X answers"), run `/follow`.
|
|
202
|
+
a configured source (`watch.sources`). When the user asks you to follow such an item, run `/follow`.
|
|
205
203
|
|
|
206
204
|
A restart is safe: the watcher keeps what it saw between runs. A stop during a report re-delivers that
|
|
207
205
|
report's events on the next arm; vet a repeated event against `gh pr view` or the run before acting
|
|
@@ -215,17 +213,18 @@ twice.
|
|
|
215
213
|
(`TaskStop`) and start it under the Monitor tool.
|
|
216
214
|
- **Only when the session has no Monitor tool**, even after `ToolSearch`, the watcher runs without
|
|
217
215
|
`--stream` as its own Bash call with `run_in_background: true`. It returns after one report and is
|
|
218
|
-
started again after every wake-up. Tell the user in one sentence that this session watches without a stream
|
|
219
|
-
|
|
216
|
+
started again after every wake-up. Tell the user in one sentence that this session watches without a stream,
|
|
217
|
+
unless the provider has none (above). A watcher sent off with a trailing `&` finishes unseen.
|
|
220
218
|
- **A wake-up is done when every watched lane has a started next action or a named reason to wait**
|
|
221
219
|
(`waiting for merge #<n>`, `waiting for mockup approval`, `plan finished, close the lane?`).
|
|
222
220
|
- **Start the stream again only when it ended:** the monitor expired (its notice after 30 minutes),
|
|
223
221
|
`stopped by SIGTERM` from outside, or `watcher stopped: <reason>`. Start it before answering the
|
|
224
222
|
user. A watcher with named lanes is restarted with every lane still running.
|
|
225
223
|
|
|
226
|
-
**Herd for the user.** The
|
|
227
|
-
|
|
228
|
-
|
|
224
|
+
**Herd for the user.** The herd shows the user where every lane and you stand: report each step of
|
|
225
|
+
your own with `jh task start "<task>"` from the main checkout and
|
|
226
|
+
`jh task done` at its end. The watcher keeps
|
|
227
|
+
the herd panes beside yours and each lane's and prints a line per view it started. On `herd --ensure failed, still watching: <reason>`, fix the cause and run
|
|
229
228
|
`bunx jackherd herd --ensure` from your pane. How the herd picks a lane's phases is in
|
|
230
229
|
[`references/herd.md`](references/herd.md). `herd --once` prints a single frame for you to read; never
|
|
231
230
|
run the live view in your own shell.
|
|
@@ -248,10 +247,11 @@ On wake-up:
|
|
|
248
247
|
| `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
|
|
249
248
|
| `deploy …` | Result and target as the run states them, to the user; a target `unknown (…)` is reported as unknown, never guessed. A failed run goes to the user as an `AskUserQuestion`: read the logs now or later |
|
|
250
249
|
| `due <when> · <action>` | An open appointment fell due (**An agreed time**, below); the action is cut to 160 characters, the board row holds it whole. Carry it out as agreed, under the approvals it needs (§ 5), then write the outcome into its status cell in the same turn |
|
|
251
|
-
| `heartbeat` | One line of status to the user; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): the `for you` part of that line is the count only (`3 wait for you, see the herd`, in the language of the replies);
|
|
250
|
+
| `heartbeat` | One line of status to the user; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): the `for you` part of that line is the count only (`3 wait for you, see the herd`, in the language of the replies); no reply of yours ends with a list of the rows of the board section |
|
|
252
251
|
| `gh unavailable` | Tell the user once; herdr watching continues |
|
|
253
252
|
| `lane <name> warn` | One line to the user: the lane's fill and that it hands over at a phase boundary if it rises further; no step in the lane |
|
|
254
253
|
| `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
|
|
254
|
+
| `lane <name> stalled <n> min` | Read its screen: running subagents or moving output are a reason to wait; a stopped lane gets its next step |
|
|
255
255
|
| `approval <name> <id> <tool> · <call>` | A call of the lane waits for the user's approval (**Approvals**, below); the duty line names the question's mark and labels |
|
|
256
256
|
| `coordinator warn` / `coordinator alert` | § 8, for yourself |
|
|
257
257
|
| `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5); a name `<lane>-<purpose>` without a worktree is a helper agent of that lane and needs no row. One the lane did not announce goes to the user |
|
package/package.json
CHANGED
package/src/plan-format/clock.ts
CHANGED
|
@@ -5,8 +5,14 @@ export const DAY_MS = 86_400_000
|
|
|
5
5
|
/** A day, month, hour or minute with a leading zero: `7` → `07`. */
|
|
6
6
|
export const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
7
7
|
|
|
8
|
+
/** The time of day: `HH:MM`. */
|
|
9
|
+
export const timeOfDayText = (at: number): string => {
|
|
10
|
+
const date = new Date(at)
|
|
11
|
+
return `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/** A time as the for-you rows show it: `DD.MM. HH:MM`. */
|
|
9
15
|
export const clockText = (at: number): string => {
|
|
10
16
|
const date = new Date(at)
|
|
11
|
-
return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${
|
|
17
|
+
return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${timeOfDayText(at)}`
|
|
12
18
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ContextReading } from '../context'
|
|
2
|
+
import { COORDINATOR_NAME_PREFIX, resolveCoordinatorName } from '../context/coordinator-name'
|
|
3
|
+
import { clockText, timeOfDayText } from '../plan-format/clock'
|
|
4
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
6
|
+
import { fit, visibleLength } from '../terminal/text'
|
|
7
|
+
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
8
|
+
import { laneOfDirectory } from './active-tasks'
|
|
9
|
+
import { statusWord } from './symbols'
|
|
10
|
+
|
|
11
|
+
export type CoordinatorTask = Pick<TaskSignal, 'text' | 'startedAt'>
|
|
12
|
+
|
|
13
|
+
/** What the herd's coordinator line shows: the agent's name and state, its task, and its fill when the log gives one. */
|
|
14
|
+
export type CoordinatorView = {
|
|
15
|
+
name: string
|
|
16
|
+
status: string | null
|
|
17
|
+
task: CoordinatorTask | null
|
|
18
|
+
context: ContextReading | null
|
|
19
|
+
/** When the herd read it: a task of the same day shows its time of day, an older one its date. */
|
|
20
|
+
readAt: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How the coordinator's text is set: the cells it may take, the paint of its state, the style of its task. */
|
|
24
|
+
export type CoordinatorTextOptions = {
|
|
25
|
+
width?: number
|
|
26
|
+
paintState?: (state: string) => string
|
|
27
|
+
taskStyle?: (task: string) => string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The coordinator among the terminal's agents: `jack-<repository>` (the default name of `/orchestrate` § 0), else the
|
|
32
|
+
* old `orchestrator-<repository>`; null when none runs. A name given to the watcher with `--coordinator` is not known here.
|
|
33
|
+
*/
|
|
34
|
+
export const coordinatorAgentOf = (agents: readonly LaneAgent[], repository: string): LaneAgent | null => {
|
|
35
|
+
const name = resolveCoordinatorName(
|
|
36
|
+
repository,
|
|
37
|
+
agents.map((agent) => agent.name),
|
|
38
|
+
)
|
|
39
|
+
return agents.find((agent) => agent.name === name) ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The coordinator's task: the newest signal reported outside the lanes' worktrees, where the coordinator works (its
|
|
44
|
+
* Claude task list, or `jh task start` from the main checkout); null without one.
|
|
45
|
+
*/
|
|
46
|
+
export const coordinatorTaskOf = (signals: readonly TaskSignal[], worktrees: string): TaskSignal | null =>
|
|
47
|
+
signals
|
|
48
|
+
.filter(({ cwd }) => laneOfDirectory(cwd, worktrees) === null)
|
|
49
|
+
.reduce<TaskSignal | null>(
|
|
50
|
+
(newest, signal) => (newest && newest.startedAt >= signal.startedAt ? newest : signal),
|
|
51
|
+
null,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const isSameDay = (first: number, second: number): boolean =>
|
|
55
|
+
new Date(first).toDateString() === new Date(second).toDateString()
|
|
56
|
+
|
|
57
|
+
/** `HH:MM` for a time of today, `DD.MM. HH:MM` for an earlier day. */
|
|
58
|
+
const sinceText = (startedAt: number, now: number): string =>
|
|
59
|
+
isSameDay(startedAt, now) ? timeOfDayText(startedAt) : clockText(startedAt)
|
|
60
|
+
|
|
61
|
+
const roleName = (prefix: string): string => `${prefix.charAt(0).toUpperCase()}${prefix.slice(1)}`
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The role name `Jack` for `jack-shop`, `Orchestrator` for the old `orchestrator-shop`: the herd's title names the
|
|
65
|
+
* repository. Another name stays as it is, made terminal safe.
|
|
66
|
+
*/
|
|
67
|
+
export const coordinatorShortName = (name: string): string => {
|
|
68
|
+
const prefix = Object.values(COORDINATOR_NAME_PREFIX).find((each) => name === each || name.startsWith(`${each}-`))
|
|
69
|
+
return prefix ? roleName(prefix) : terminalSafe(name)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TEXT_SEPARATOR = ' · '
|
|
73
|
+
/** The fewest cells a cut task keeps: one character and its ellipsis. */
|
|
74
|
+
const MIN_TASK_WIDTH = 2
|
|
75
|
+
|
|
76
|
+
const unstyled = (text: string): string => text
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `working · <task> · since 12:30` in at most `width` cells, the state in `paintState`, the task alone in
|
|
80
|
+
* `taskStyle` (the herd's shimmer): the task is cut with `…`, state and since when never; the task is left out where
|
|
81
|
+
* not even its ellipsis fits. Only a working coordinator shows its task: at rest, or without a task, the state
|
|
82
|
+
* stands alone (`idle`), so an old task does not read as running.
|
|
83
|
+
*/
|
|
84
|
+
export const coordinatorText = (
|
|
85
|
+
{ status, task }: Pick<CoordinatorView, 'status' | 'task'>,
|
|
86
|
+
now: number,
|
|
87
|
+
{ width = Number.POSITIVE_INFINITY, paintState = unstyled, taskStyle = unstyled }: CoordinatorTextOptions = {},
|
|
88
|
+
): string => {
|
|
89
|
+
const state = terminalSafe(statusWord(status))
|
|
90
|
+
if (!task || status !== AGENT_STATUS.working) return paintState(state)
|
|
91
|
+
const since = `since ${sinceText(task.startedAt, now)}`
|
|
92
|
+
const room = width - visibleLength(state) - visibleLength(since) - 2 * TEXT_SEPARATOR.length
|
|
93
|
+
const middle = room < MIN_TASK_WIDTH ? [] : [taskStyle(fit(terminalSafe(task.text), room))]
|
|
94
|
+
return [paintState(state), ...middle, since].join(TEXT_SEPARATOR)
|
|
95
|
+
}
|
package/src/plans/herd-state.ts
CHANGED
|
@@ -82,15 +82,19 @@ export const storedRead = (read: PublishedRead, key: string, at: number): HerdRe
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* The snapshot with its clocks set to `now`: the sources' ages
|
|
86
|
-
* time with every read.
|
|
85
|
+
* The snapshot with its clocks set to `now`: the sources' ages, your turn's and the coordinator's, which the reader
|
|
86
|
+
* draws at its own time with every read.
|
|
87
87
|
*/
|
|
88
|
-
const clockedAt = <T extends Pick<Snapshot, 'sourceBlock' | 'yourTurn'>>(
|
|
89
|
-
|
|
88
|
+
const clockedAt = <T extends Pick<Snapshot, 'sourceBlock' | 'yourTurn' | 'coordinator'>>(
|
|
89
|
+
snapshot: T,
|
|
90
|
+
now: number,
|
|
91
|
+
): T => {
|
|
92
|
+
const { sourceBlock, yourTurn, coordinator, ...rest } = snapshot
|
|
90
93
|
return {
|
|
91
94
|
...rest,
|
|
92
95
|
...(sourceBlock ? { sourceBlock: { ...sourceBlock, now } } : {}),
|
|
93
96
|
...(yourTurn ? { yourTurn: { ...yourTurn, now } } : {}),
|
|
97
|
+
...(coordinator ? { coordinator: { ...coordinator, readAt: now } } : {}),
|
|
94
98
|
} as T
|
|
95
99
|
}
|
|
96
100
|
|
|
@@ -147,6 +151,7 @@ export const laneRead = (snapshot: Snapshot, lane: string, laneNotListed: (lane:
|
|
|
147
151
|
titles: new Map([...snapshot.titles].filter(([titled]) => titled === pane)),
|
|
148
152
|
panes: pane ? { [lane]: pane } : {},
|
|
149
153
|
unplaced: [],
|
|
154
|
+
coordinator: undefined,
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
157
|
|
package/src/plans/layout.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from '../watch/plan-badges'
|
|
34
34
|
import type { UnplannedTask } from './active-tasks'
|
|
35
35
|
import { type AgentOf, clickHint } from './click-hint'
|
|
36
|
+
import { type CoordinatorView, coordinatorShortName, coordinatorText } from './coordinator-row'
|
|
36
37
|
import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame, unpainted } from './frame'
|
|
37
38
|
import { groomedText } from './groomed'
|
|
38
39
|
import {
|
|
@@ -161,6 +162,8 @@ export type OverviewView = {
|
|
|
161
162
|
groomedDays?: number | null
|
|
162
163
|
/** What agents report doing that no lane takes, one `now:` line each under the title. */
|
|
163
164
|
unplaced?: UnplannedTask[]
|
|
165
|
+
/** The coordinator's state, task and fill, one line over the lanes, under the blocks on top. */
|
|
166
|
+
coordinator?: CoordinatorView
|
|
164
167
|
/** True when a folded lane's running task shimmers (`shimmerAt` draws it) instead of carrying its `●`. */
|
|
165
168
|
shimmer?: boolean
|
|
166
169
|
}
|
|
@@ -457,6 +460,26 @@ const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
|
|
|
457
460
|
const contextValue = (context: ContextReading | null | undefined): string =>
|
|
458
461
|
context ? `${CONTEXT_SYMBOL}${CONTEXT_PAINT[context.level](contextLabel(context.share).padStart(CONTEXT_WIDTH))}` : ''
|
|
459
462
|
|
|
463
|
+
/** The coordinator's state word: working in the tone of working lanes, at rest dim as in the head of the lanes. */
|
|
464
|
+
const coordinatorStatePaint = (status: string | null): ((text: string) => string) =>
|
|
465
|
+
status === AGENT_STATUS.working || status === AGENT_STATUS.blocked ? statusPaint(status) : paint.dim
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The coordinator's line over the lanes, set like the head of a block of one line: its state's symbol where a head
|
|
469
|
+
* has its chevron, one cell wide, so the name starts in the column of the heads' names; then
|
|
470
|
+
* `Jack · working · <task> · since 12:30`, its fill at the right edge where the lanes have it. The task alone is cut
|
|
471
|
+
* to fit, and drawn in `taskStyle` like the running task of a folded lane: it shimmers while the herd does.
|
|
472
|
+
*/
|
|
473
|
+
const coordinatorLine = (view: CoordinatorView, width: number, taskStyle: TaskStyle): string => {
|
|
474
|
+
const paintState = coordinatorStatePaint(view.status)
|
|
475
|
+
const name = paint.bold(coordinatorShortName(view.name))
|
|
476
|
+
const head = `${paintState(statusSymbol(view.status))} ${name}${PLACE_SEPARATOR}`
|
|
477
|
+
const context = contextValue(view.context)
|
|
478
|
+
const room = width - visibleLength(head) - (context ? visibleLength(context) + 1 : 0)
|
|
479
|
+
const text = coordinatorText(view, view.readAt, { width: room, paintState, taskStyle })
|
|
480
|
+
return spread(`${head}${text}`, context, width)
|
|
481
|
+
}
|
|
482
|
+
|
|
460
483
|
/**
|
|
461
484
|
* The context value with a rule before the bar that follows it on a folded lane line, from `warn` on; below, blanks
|
|
462
485
|
* as wide, so nothing moves when it shows. Empty without a reading.
|
|
@@ -1344,6 +1367,7 @@ export const renderScreen = (
|
|
|
1344
1367
|
sourceBlock,
|
|
1345
1368
|
groomedDays,
|
|
1346
1369
|
unplaced = [],
|
|
1370
|
+
coordinator,
|
|
1347
1371
|
shimmer,
|
|
1348
1372
|
}: OverviewView,
|
|
1349
1373
|
state: Selection,
|
|
@@ -1367,7 +1391,11 @@ export const renderScreen = (
|
|
|
1367
1391
|
textFrame(parkedBlock(noAgent, width)),
|
|
1368
1392
|
...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
|
|
1369
1393
|
]
|
|
1370
|
-
const plainHeader = [
|
|
1394
|
+
const plainHeader = [
|
|
1395
|
+
...above,
|
|
1396
|
+
...(stack.lines.length > 0 ? stack.lines : ['']),
|
|
1397
|
+
...(coordinator ? [coordinatorLine(coordinator, width, taskStyle), ''] : []),
|
|
1398
|
+
]
|
|
1371
1399
|
const plainBody = spaced(sections)
|
|
1372
1400
|
const blockHits = stack.hits.flatMap(focusHitsOfBlock)
|
|
1373
1401
|
const focusHits = [...blockHits, ...focusHitsOfTargets(plainBody.targets, plainHeader.length)]
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
3
|
import type { ContextReading } from '../context'
|
|
4
|
+
import { coordinatorSettings } from '../context/options'
|
|
4
5
|
import { type AgentLogReader, contextOfAgent } from '../context/report'
|
|
5
6
|
import { agentLog, defaultProjectsDirectory } from '../context/session-log'
|
|
6
7
|
import {
|
|
@@ -25,6 +26,7 @@ import { readWatchReading } from '../watch/plan-badges-read'
|
|
|
25
26
|
import type { SourceStatus } from '../watch/source-statuses'
|
|
26
27
|
import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
|
|
27
28
|
import { type BoardRowsReader, boardRowsOf } from './board-rows'
|
|
29
|
+
import { type CoordinatorView, coordinatorAgentOf, coordinatorTaskOf } from './coordinator-row'
|
|
28
30
|
import {
|
|
29
31
|
type ActivityTimes,
|
|
30
32
|
type LaneMemory,
|
|
@@ -140,6 +142,8 @@ export type Snapshot = {
|
|
|
140
142
|
tones?: PlansSettings['tones']
|
|
141
143
|
/** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
|
|
142
144
|
unplaced?: UnplannedTask[]
|
|
145
|
+
/** The coordinator's state, task and fill, for its line over the lanes; absent while none runs or one lane is shown. */
|
|
146
|
+
coordinator?: CoordinatorView
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
type LaneSources = {
|
|
@@ -414,6 +418,25 @@ const readSignals = async (
|
|
|
414
418
|
}
|
|
415
419
|
}
|
|
416
420
|
|
|
421
|
+
/**
|
|
422
|
+
* The coordinator's line for the overview, with the signal it shows as its task: its agent's state, that task and its
|
|
423
|
+
* fill against the coordinator's thresholds; null while no coordinator runs.
|
|
424
|
+
*/
|
|
425
|
+
const coordinatorOf = (sources: LaneSources): { view: CoordinatorView; shown: TaskSignal | null } | null => {
|
|
426
|
+
const agent = coordinatorAgentOf(sources.agents, basename(sources.source.root))
|
|
427
|
+
if (!agent) return null
|
|
428
|
+
const shown = coordinatorTaskOf(sources.signals, sources.worktrees)
|
|
429
|
+
const report = contextOfAgent(agent, sources.readLog, coordinatorSettings(sources.source.settings.context))
|
|
430
|
+
const view = {
|
|
431
|
+
name: agent.name,
|
|
432
|
+
status: agent.status,
|
|
433
|
+
task: shown && { text: shown.text, startedAt: shown.startedAt },
|
|
434
|
+
context: report && { share: report.share, level: report.level },
|
|
435
|
+
readAt: sources.now,
|
|
436
|
+
}
|
|
437
|
+
return { view, shown }
|
|
438
|
+
}
|
|
439
|
+
|
|
417
440
|
/** The reports no shown lane takes, as lines for the overview: the coordinator's own and those of lanes without a plan. */
|
|
418
441
|
const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane: boolean): UnplannedTask[] => {
|
|
419
442
|
if (onlyLane) return []
|
|
@@ -721,6 +744,11 @@ export const loadSnapshot = async (
|
|
|
721
744
|
baseCommit,
|
|
722
745
|
})
|
|
723
746
|
if (parsed) dropUnused(parsed.entries, parsed.used)
|
|
747
|
+
const coordinator = options.lane ? null : coordinatorOf(laneSet.sources)
|
|
748
|
+
const unplacedSources = {
|
|
749
|
+
...laneSet.sources,
|
|
750
|
+
signals: laneSet.sources.signals.filter((signal) => signal !== coordinator?.shown),
|
|
751
|
+
}
|
|
724
752
|
await gate.pass()
|
|
725
753
|
return {
|
|
726
754
|
title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
|
|
@@ -746,7 +774,8 @@ export const loadSnapshot = async (
|
|
|
746
774
|
yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
|
|
747
775
|
...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, settings), now } } : {}),
|
|
748
776
|
tones: settings.tones,
|
|
749
|
-
|
|
777
|
+
...(coordinator ? { coordinator: coordinator.view } : {}),
|
|
778
|
+
unplaced: unplacedOf(unplacedSources, laneSet.lanes, Boolean(options.lane)),
|
|
750
779
|
...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
|
|
751
780
|
}
|
|
752
781
|
}
|
package/src/plans/view.ts
CHANGED
package/src/watch/command.ts
CHANGED
|
@@ -87,7 +87,7 @@ import {
|
|
|
87
87
|
import { type FeedbackItem } from './feedback-item'
|
|
88
88
|
import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
|
|
89
89
|
import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
|
|
90
|
-
import { currentPhaseCountsOf } from './heartbeat-progress'
|
|
90
|
+
import { currentPhaseCountsOf, type LaneNotes } from './heartbeat-progress'
|
|
91
91
|
import { startHerdPublisher } from './herd-publisher'
|
|
92
92
|
import { agentScreen, listAgents } from './herdr'
|
|
93
93
|
import { endedByKitUpdate } from './kit-update'
|
|
@@ -120,6 +120,7 @@ import {
|
|
|
120
120
|
import { hasLaneHandover, laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
|
|
121
121
|
import { restNote } from './lane-rest'
|
|
122
122
|
import { branchOf, fetchBaseWhenMoved, fetchPlanHeadsWhenMoved, readMainCheckout } from './lane-sources'
|
|
123
|
+
import { type LaneStalledEvent, laneStalledEvents, STALLED_HEARTBEATS, type StallState } from './lane-stalled'
|
|
123
124
|
import {
|
|
124
125
|
advanceBaseline,
|
|
125
126
|
armBaseline,
|
|
@@ -306,6 +307,8 @@ type Watching = {
|
|
|
306
307
|
parallelAutoEnded: string | null
|
|
307
308
|
/** Since when each lane works without a task signal and the phase last reported; persisted like the fingerprints. */
|
|
308
309
|
noTaskSignals: NoSignalState
|
|
310
|
+
/** The working lanes' positions at the last heartbeat and since when they stand; persisted like the fingerprints. */
|
|
311
|
+
laneStalls: StallState
|
|
309
312
|
/** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
|
|
310
313
|
unlinkedArtifacts: UnlinkedState
|
|
311
314
|
/** The approval requests of the last poll; null before the first read, so the stored ids stay. */
|
|
@@ -360,6 +363,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
|
|
|
360
363
|
taskParallels: watching.taskParallels,
|
|
361
364
|
parallelAutoEnded: watching.parallelAutoEnded,
|
|
362
365
|
noTaskSignals: watching.noTaskSignals,
|
|
366
|
+
laneStalls: watching.laneStalls,
|
|
363
367
|
unlinkedArtifacts: watching.unlinkedArtifacts,
|
|
364
368
|
approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
|
|
365
369
|
sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
|
|
@@ -1160,11 +1164,27 @@ const subagentCountsOf = (watch: Watch, now: number): Record<string, number> =>
|
|
|
1160
1164
|
}
|
|
1161
1165
|
}
|
|
1162
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* This heartbeat's `stalled` events: the working lanes whose position (the heartbeat's note) stood still for
|
|
1169
|
+
* `STALLED_HEARTBEATS` heartbeat intervals, each once per stretch; records the state in `watching`.
|
|
1170
|
+
*/
|
|
1171
|
+
const laneStalledEventsOf = (watch: Watch, watching: Watching, notes: LaneNotes, now: number): LaneStalledEvent[] => {
|
|
1172
|
+
const working = new Set(workingLanes(watching))
|
|
1173
|
+
const readings = Object.fromEntries(
|
|
1174
|
+
watching.lanes.map((lane) => [lane, { working: working.has(lane), position: notes[lane] ?? '' }]),
|
|
1175
|
+
)
|
|
1176
|
+
const limitMinutes = watch.options.heartbeatMinutes * STALLED_HEARTBEATS
|
|
1177
|
+
const { events, state } = laneStalledEvents(watching.laneStalls, readings, now, limitMinutes)
|
|
1178
|
+
watching.laneStalls = state
|
|
1179
|
+
return events
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1163
1182
|
/**
|
|
1164
1183
|
* The heartbeat of a stream: one line, a working lane with its phase count at every heartbeat and the subagents running
|
|
1165
|
-
* in its worktree, printed only when its state changed since the last one sent
|
|
1166
|
-
*
|
|
1167
|
-
* the
|
|
1184
|
+
* in its worktree, printed only when its state changed since the last one sent; a lane whose position stood still for
|
|
1185
|
+
* `STALLED_HEARTBEATS` intervals comes as its own `stalled` event. The plans are read only when a lane works; a board that cannot be
|
|
1186
|
+
* read leaves the lines without a count, and the next report names the failure. Returns the state of the heartbeat it
|
|
1187
|
+
* printed, null when it printed none.
|
|
1168
1188
|
*/
|
|
1169
1189
|
const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<string | null> => {
|
|
1170
1190
|
const working = workingLanes(watching)
|
|
@@ -1182,6 +1202,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
1182
1202
|
const sent = isHeartbeatNews(watching.state.heartbeat, line) ? heartbeatStateOf(line) : null
|
|
1183
1203
|
if (sent !== null) console.log(line)
|
|
1184
1204
|
const events = [
|
|
1205
|
+
...laneStalledEventsOf(watch, watching, notes, now),
|
|
1185
1206
|
...taskParallelEventsOf(watch, watching, now),
|
|
1186
1207
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1187
1208
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
@@ -1284,6 +1305,7 @@ const initialWatching = (
|
|
|
1284
1305
|
taskParallels: start.state.taskParallels,
|
|
1285
1306
|
parallelAutoEnded: start.state.parallelAutoEnded,
|
|
1286
1307
|
noTaskSignals: start.state.noTaskSignals,
|
|
1308
|
+
laneStalls: start.state.laneStalls,
|
|
1287
1309
|
unlinkedArtifacts: start.state.unlinkedArtifacts,
|
|
1288
1310
|
approvalRequests: null,
|
|
1289
1311
|
appointments: null,
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type FeedbackEvent, firstLine } from './feedback'
|
|
|
11
11
|
import type { LaneFillEvent } from './lane-fill'
|
|
12
12
|
import type { LaneModelEvent } from './lane-model'
|
|
13
13
|
import type { LaneParallelEvent } from './lane-parallel'
|
|
14
|
+
import { laneStalledLine } from './lane-stalled'
|
|
14
15
|
import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
|
|
15
16
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
16
17
|
import { SOURCE_FIELD } from './source-fields'
|
|
@@ -150,6 +151,19 @@ export const otherPullRequestsLine = (
|
|
|
150
151
|
return `${texts.otherPullRequests}: ${[...named, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
/** The header line of an event about one lane's agent (fill, model, parallel phases, task signal, standing still); null for any other. */
|
|
155
|
+
const laneEventLine = (event: WatchEvent): string | null => {
|
|
156
|
+
if (event.kind === WATCH_EVENT.laneFill)
|
|
157
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
|
|
158
|
+
if (event.kind === WATCH_EVENT.laneModel)
|
|
159
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
|
|
160
|
+
if (event.kind === WATCH_EVENT.laneParallel)
|
|
161
|
+
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
162
|
+
if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
|
|
163
|
+
if (event.kind === WATCH_EVENT.laneStalled) return `=== ${terminalSafe(laneStalledLine(event))}`
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
153
167
|
/**
|
|
154
168
|
* The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>): <checks> (<k> tests: <names>)`,
|
|
155
169
|
* the checks and tests only when the event carries them,
|
|
@@ -167,14 +181,9 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
|
|
|
167
181
|
if (event.kind === WATCH_EVENT.ghUnavailable) return `=== ${event.kind}: ${terminalSafe(event.reason)}`
|
|
168
182
|
if (event.kind === WATCH_EVENT.preview)
|
|
169
183
|
return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.url)} · ${terminalSafe(event.headCommit.slice(0, SHORT_SHA_LENGTH))}`
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (event.kind === WATCH_EVENT.laneModel)
|
|
173
|
-
return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
|
|
174
|
-
if (event.kind === WATCH_EVENT.laneParallel)
|
|
175
|
-
return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
|
|
184
|
+
const laneLine = laneEventLine(event)
|
|
185
|
+
if (laneLine !== null) return laneLine
|
|
176
186
|
if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
|
|
177
|
-
if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
|
|
178
187
|
if (event.kind === WATCH_EVENT.unlinkedArtifact) return `=== ${unlinkedArtifactLine(event)}`
|
|
179
188
|
if (event.kind === WATCH_EVENT.approval)
|
|
180
189
|
return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
2
|
+
import { isRecord } from '../shared/is-record'
|
|
3
|
+
|
|
4
|
+
export const LANE_STALLED_EVENT = 'stalled'
|
|
5
|
+
|
|
6
|
+
/** How many heartbeat intervals a working lane's position stands still before it is reported. */
|
|
7
|
+
export const STALLED_HEARTBEATS = 3
|
|
8
|
+
|
|
9
|
+
/** What a heartbeat read of a lane: whether its agent works, and its position as the heartbeat shows it (`P12 7/12, 3 subagents`, `''` without one). */
|
|
10
|
+
export type StallReading = { working: boolean; position: string }
|
|
11
|
+
|
|
12
|
+
/** Per working lane: the position last read, since when it stands, and whether that stretch was reported. */
|
|
13
|
+
export type StallState = Record<string, { position: string; since: number; reported: boolean }>
|
|
14
|
+
|
|
15
|
+
export type LaneStalledEvent = { kind: typeof LANE_STALLED_EVENT; lane: string; minutes: number }
|
|
16
|
+
|
|
17
|
+
type LaneEntry = StallState[string]
|
|
18
|
+
|
|
19
|
+
const nextEntry = (previous: LaneEntry | undefined, position: string, now: number): LaneEntry =>
|
|
20
|
+
previous?.position === position ? previous : { position, since: now, reported: false }
|
|
21
|
+
|
|
22
|
+
const standingMinutes = (entry: LaneEntry, now: number): number => Math.floor((now - entry.since) / MS_PER_MINUTE)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The working lanes whose position (phase, ticks, subagents) stood still for `limitMinutes` or longer, one event per
|
|
26
|
+
* stretch: a moved position starts the clock again and may be reported again, a lane that stops working or has no
|
|
27
|
+
* reading drops its clock.
|
|
28
|
+
*/
|
|
29
|
+
export const laneStalledEvents = (
|
|
30
|
+
previous: StallState,
|
|
31
|
+
readings: Readonly<Record<string, StallReading>>,
|
|
32
|
+
now: number,
|
|
33
|
+
limitMinutes: number,
|
|
34
|
+
): { events: LaneStalledEvent[]; state: StallState } => {
|
|
35
|
+
const events: LaneStalledEvent[] = []
|
|
36
|
+
const state: StallState = {}
|
|
37
|
+
for (const [lane, { working, position }] of Object.entries(readings)) {
|
|
38
|
+
if (!working) continue
|
|
39
|
+
const entry = nextEntry(previous[lane], position, now)
|
|
40
|
+
const minutes = standingMinutes(entry, now)
|
|
41
|
+
const due = !entry.reported && minutes >= limitMinutes
|
|
42
|
+
if (due) events.push({ kind: LANE_STALLED_EVENT, lane, minutes })
|
|
43
|
+
state[lane] = due ? { ...entry, reported: true } : entry
|
|
44
|
+
}
|
|
45
|
+
return { events, state }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The line the coordinator reads: `lane <name> stalled <n> min`. */
|
|
49
|
+
export const laneStalledLine = ({ lane, minutes }: LaneStalledEvent): string =>
|
|
50
|
+
`lane ${lane} ${LANE_STALLED_EVENT} ${minutes} min`
|
|
51
|
+
|
|
52
|
+
const isEntry = (value: unknown): boolean =>
|
|
53
|
+
isRecord(value) &&
|
|
54
|
+
typeof value.position === 'string' &&
|
|
55
|
+
typeof value.since === 'number' &&
|
|
56
|
+
typeof value.reported === 'boolean'
|
|
57
|
+
|
|
58
|
+
export const isStallState = (value: unknown): value is StallState =>
|
|
59
|
+
isRecord(value) && Object.values(value).every(isEntry)
|
|
@@ -14,6 +14,7 @@ import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackSt
|
|
|
14
14
|
import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
|
|
15
15
|
import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels } from './lane-model'
|
|
16
16
|
import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
|
|
17
|
+
import { isStallState, LANE_STALLED_EVENT, type LaneStalledEvent, type StallState } from './lane-stalled'
|
|
17
18
|
import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
|
|
18
19
|
import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
|
|
19
20
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
|
|
@@ -57,6 +58,7 @@ export const WATCH_EVENT = {
|
|
|
57
58
|
laneParallel: LANE_PARALLEL_EVENT,
|
|
58
59
|
taskParallel: TASK_PARALLEL_EVENT,
|
|
59
60
|
noTaskSignal: NO_TASK_SIGNAL_EVENT,
|
|
61
|
+
laneStalled: LANE_STALLED_EVENT,
|
|
60
62
|
unlinkedArtifact: UNLINKED_ARTIFACT_EVENT,
|
|
61
63
|
approval: APPROVAL_EVENT,
|
|
62
64
|
source: SOURCE_EVENT,
|
|
@@ -116,6 +118,8 @@ export type WatchState = {
|
|
|
116
118
|
parallelAutoEnded: string | null
|
|
117
119
|
/** Per watched lane: since when it works without a task signal and the phase last reported as `no task signal`. */
|
|
118
120
|
noTaskSignals: NoSignalState
|
|
121
|
+
/** Per working lane: its position at the last heartbeat, since when it stands and whether that was reported as `stalled`. */
|
|
122
|
+
laneStalls: StallState
|
|
119
123
|
/** Per open artifact send no plan or board links: in its grace or already reported as `unlinked artifact`. */
|
|
120
124
|
unlinkedArtifacts: UnlinkedState
|
|
121
125
|
/** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
|
|
@@ -179,6 +183,7 @@ export type WatchEvent =
|
|
|
179
183
|
| LaneParallelEvent
|
|
180
184
|
| TaskParallelEvent
|
|
181
185
|
| NoTaskSignalEvent
|
|
186
|
+
| LaneStalledEvent
|
|
182
187
|
| UnlinkedArtifactEvent
|
|
183
188
|
| ApprovalEvent
|
|
184
189
|
| SourceEvent
|
|
@@ -217,6 +222,7 @@ export const emptyWatchState = (): WatchState => ({
|
|
|
217
222
|
taskParallels: {},
|
|
218
223
|
parallelAutoEnded: null,
|
|
219
224
|
noTaskSignals: {},
|
|
225
|
+
laneStalls: {},
|
|
220
226
|
unlinkedArtifacts: {},
|
|
221
227
|
approvals: [],
|
|
222
228
|
sources: emptySourceState(),
|
|
@@ -269,6 +275,7 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
|
|
|
269
275
|
isTaskParallelFingerprints(value.taskParallels) &&
|
|
270
276
|
(value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
|
|
271
277
|
isNoSignalState(value.noTaskSignals) &&
|
|
278
|
+
isStallState(value.laneStalls) &&
|
|
272
279
|
isUnlinkedState(value.unlinkedArtifacts) &&
|
|
273
280
|
Array.isArray(value.approvals) &&
|
|
274
281
|
value.approvals.every((id) => typeof id === 'string') &&
|