@plot-pm/board 0.16.1 → 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +167 -139
- package/package.json +2 -3
- package/plot-agent-manifest.sh +111 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +234 -74
- package/plot-build-monitor.sh +49 -4
- package/plot-config.sh +10 -14
- package/plot-deliver.sh +10 -732
- package/plot-desk-root.sh +127 -0
- package/plot-dispatch.sh +313 -135
- package/plot-fleet-scan.sh +746 -62
- package/plot-host.sh +356 -43
- package/plot-monitor-subject.sh +55 -3
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +82 -93
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +90 -14
- package/plot-transcript-quiet.sh +3 -1
- package/plot-worker-state.sh +633 -24
- package/plot-resolve-artifact.sh +0 -429
- package/plot-worker-monitor.sh +0 -705
package/plot-worker-state.sh
CHANGED
|
@@ -88,6 +88,16 @@
|
|
|
88
88
|
# within an hour of removing it at large scale.
|
|
89
89
|
PLOT_WORKER_RECORD='\.plot-worker\.'
|
|
90
90
|
|
|
91
|
+
# `exclude_bundle_paths` (`plot-desk-dirt.sh`), SOURCED SO `plot_worker_dirty_filter`
|
|
92
|
+
# CAN EXCUSE A REBUILT BUNDLE THE SAME WAY `desk_dirt` DOES. A sibling path,
|
|
93
|
+
# the shape every caller of this file already sources it by (`$script_dir/` or
|
|
94
|
+
# `$(dirname "${BASH_SOURCE[0]}")/`), so the same path resolves it in turn.
|
|
95
|
+
# Best-effort: a guard reads `command -v exclude_bundle_paths` before any call,
|
|
96
|
+
# so a missing sibling degrades to the three exclusions below alone.
|
|
97
|
+
# shellcheck source=plot-desk-dirt.sh
|
|
98
|
+
desk_dirt_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-desk-dirt.sh"
|
|
99
|
+
[ -r "$desk_dirt_lib" ] && . "$desk_dirt_lib"
|
|
100
|
+
|
|
91
101
|
# ---------------------------------------------------------------------------
|
|
92
102
|
# THE REGISTRY HOLDS THE PID — the anchor moved from worktree to manifest
|
|
93
103
|
# ---------------------------------------------------------------------------
|
|
@@ -105,31 +115,132 @@ PLOT_WORKER_RECORD='\.plot-worker\.'
|
|
|
105
115
|
# its number. Without it, dead pids are one `fork()` away from reading `running`.
|
|
106
116
|
#
|
|
107
117
|
# THE WORKTREE→MANIFEST LOOKUP. The manifest directory lives at
|
|
108
|
-
# `$PLOT_MANIFEST_DIR` when the caller sets it,
|
|
109
|
-
#
|
|
110
|
-
# finds the manifest whose worktree matches.
|
|
118
|
+
# `$PLOT_MANIFEST_DIR` when the caller sets it, and is otherwise resolved from
|
|
119
|
+
# the MAIN CHECKOUT and the `Agent registry` key. Each manifest names a
|
|
120
|
+
# `worktree` field; the lookup finds the manifest whose worktree matches.
|
|
121
|
+
#
|
|
122
|
+
# THE RULE IS `deskManifest` / `manifestDirectory` IN THE DOMAIN
|
|
123
|
+
# (`packages/domain/src/rules/desk-manifest.ts`), and this is a DECLARED
|
|
124
|
+
# DUPLICATE of it rather than a call to it. `docs/shell-and-domain.md` puts the
|
|
125
|
+
# choice on the cost: `plot_manifest_for_worktree` runs once per worktree per
|
|
126
|
+
# fleet-scan pass, and a bundle answers in about 39 ms, so a hop here is paid by
|
|
127
|
+
# every desk on every pass forever. `packages/domain/corpus/desk-manifest.corpus.test.ts`
|
|
128
|
+
# holds the pair. NEITHER SIDE IS AUTHORITATIVE: on a disagreement the branch
|
|
129
|
+
# stops, and adjusting either side to make the comparison pass is forbidden.
|
|
111
130
|
|
|
112
|
-
# The manifest directory, set by callers who know their repo root. When unset
|
|
113
|
-
#
|
|
131
|
+
# The manifest directory, set by callers who know their repo root. When unset it
|
|
132
|
+
# is resolved once at source time, below.
|
|
114
133
|
: "${PLOT_MANIFEST_DIR:=}"
|
|
115
134
|
|
|
135
|
+
# The MAIN checkout for a directory, or "" — the reading `plot_repo_root`
|
|
136
|
+
# (`plot-desk-root.sh:38-46`) makes, asked of a path rather than of the cwd.
|
|
137
|
+
#
|
|
138
|
+
# `--show-toplevel` ANSWERS THE DESK inside a linked worktree, which is the whole
|
|
139
|
+
# of #1086: this function derived `<desk>/.plot/agents` and found no manifest, so
|
|
140
|
+
# every worker-state reading taken from inside a dispatched desk read its agent
|
|
141
|
+
# as unregistered. Every linked worktree shares ONE common git dir, so the
|
|
142
|
+
# parent of `--git-common-dir` is the main checkout from anywhere.
|
|
143
|
+
#
|
|
144
|
+
# THE COMMON DIR MAY BE RELATIVE. In a linked worktree git prints an absolute
|
|
145
|
+
# path; in the main checkout it prints `.git`, relative to the tree. So it is
|
|
146
|
+
# resolved by `cd`-ing to the tree FIRST and then to the common dir, which makes
|
|
147
|
+
# both forms absolute, and `pwd -P` keeps it physical for the same reason
|
|
148
|
+
# `plot-desk-root.sh` does: `git worktree list` prints resolved paths, and a
|
|
149
|
+
# directory composed from a logical one (`/tmp` against `/private/tmp` on macOS)
|
|
150
|
+
# is a prefix no worktree path starts with.
|
|
151
|
+
plot_main_checkout_of() { # $1=directory → the main checkout, or "" (non-zero)
|
|
152
|
+
local at="$1" common root=''
|
|
153
|
+
[ -n "$at" ] && [ -d "$at" ] || return 1
|
|
154
|
+
common=$(git -C "$at" rev-parse --git-common-dir 2>/dev/null) && [ -n "$common" ] && {
|
|
155
|
+
common=$(cd -- "$at" 2>/dev/null && cd -- "$common" 2>/dev/null && pwd -P) || common=''
|
|
156
|
+
[ -n "$common" ] && root=$(dirname -- "$common")
|
|
157
|
+
}
|
|
158
|
+
[ -n "$root" ] || root=$(git -C "$at" rev-parse --show-toplevel 2>/dev/null) || return 1
|
|
159
|
+
[ -n "$root" ] || return 1
|
|
160
|
+
printf '%s' "$root"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# The `Agent registry` directory for a main checkout — `manifestDirectory`'s rule.
|
|
164
|
+
#
|
|
165
|
+
# An absolute value is taken as given, so a project may name a registry outside
|
|
166
|
+
# its own tree; a relative one joins to the MAIN CHECKOUT, never to a desk,
|
|
167
|
+
# because a desk must resolve the same directory the checkout does (`CLAUDE.md`
|
|
168
|
+
# gives that reason for `Board artifact` and `Agent settings`). An absent or
|
|
169
|
+
# empty key means `.plot/agents`. The trailing slash is trimmed, the way
|
|
170
|
+
# `plot-dispatch.sh:agent_registry_dir` trims one and `path.join` normalises
|
|
171
|
+
# one — two answers to *where is the registry* is what this removes.
|
|
172
|
+
# `PLOT_REPO_ROOT` IS PASSED AND NEVER INHERITED, which is this function's one
|
|
173
|
+
# trap. `plot-config.sh:222` takes an exported `PLOT_REPO_ROOT` in preference to
|
|
174
|
+
# asking git, and the fleet wrapper exports the DISPATCHING repository's root
|
|
175
|
+
# into every agent — so a lookup about a desk in another checkout read this
|
|
176
|
+
# repository's `CLAUDE.md`. Measured 2026-10-02 while building this slice: a
|
|
177
|
+
# fixture repo with its own `Agent registry` key answered the surrounding repo's
|
|
178
|
+
# `.plot/agents`. Its own fallback is `--show-toplevel`, which answers the DESK,
|
|
179
|
+
# so leaving the variable unset would reintroduce #1086 one layer down.
|
|
180
|
+
plot_manifest_dir_for() { # $1=main checkout → prints the directory
|
|
181
|
+
local root="$1" dir=''
|
|
182
|
+
if [ -x "$_plot_wstate_config" ] || [ -r "$_plot_wstate_config" ]; then
|
|
183
|
+
dir=$(PLOT_REPO_ROOT="$root" bash "$_plot_wstate_config" get "Agent registry" "" 2>/dev/null) || dir=''
|
|
184
|
+
fi
|
|
185
|
+
# Whitespace alone is a key nobody filled in.
|
|
186
|
+
dir=$(printf '%s' "$dir" | tr -d '[:space:]')
|
|
187
|
+
[ -n "$dir" ] || dir=".plot/agents"
|
|
188
|
+
case "$dir" in
|
|
189
|
+
/*) ;;
|
|
190
|
+
*) dir="${root%/}/$dir" ;;
|
|
191
|
+
esac
|
|
192
|
+
printf '%s' "${dir%/}"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# `plot-config.sh`, resolved ONCE at source time the way `plot-desk-root.sh:48`
|
|
196
|
+
# resolves its own bundle: reading `BASH_SOURCE[0]` inside a function reads the
|
|
197
|
+
# CALLER's file once the function has been exported.
|
|
198
|
+
_plot_wstate_config="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/plot-config.sh"
|
|
199
|
+
|
|
200
|
+
# THE DIRECTORY IS RESOLVED AT SOURCE TIME, AND THAT IS NOT A STYLE CHOICE.
|
|
201
|
+
# Both callers invoke the lookup as `manifest=$(plot_manifest_for_worktree …)`,
|
|
202
|
+
# so an assignment made inside it dies with the command substitution's subshell
|
|
203
|
+
# and a cache set there never reaches a second call. Resolving here spends one
|
|
204
|
+
# `plot-config.sh` fork per PROCESS instead of one per desk per pass.
|
|
205
|
+
#
|
|
206
|
+
# A CALLER-SET VALUE WINS and is never overwritten: the dispatcher and the tests
|
|
207
|
+
# set it, and they know their repo root without being asked.
|
|
208
|
+
if [ -z "$PLOT_MANIFEST_DIR" ]; then
|
|
209
|
+
_plot_wstate_root=$(plot_main_checkout_of "$PWD" 2>/dev/null) || _plot_wstate_root=''
|
|
210
|
+
if [ -n "$_plot_wstate_root" ]; then
|
|
211
|
+
PLOT_MANIFEST_DIR=$(plot_manifest_dir_for "$_plot_wstate_root")
|
|
212
|
+
fi
|
|
213
|
+
unset _plot_wstate_root
|
|
214
|
+
fi
|
|
215
|
+
|
|
116
216
|
# Find the manifest for a worktree → the full path, or "" (non-zero).
|
|
117
217
|
#
|
|
118
|
-
# Iterates
|
|
119
|
-
# dispatcher records the RESOLVED worktree path
|
|
120
|
-
#
|
|
218
|
+
# Iterates the registry's `*.json` and matches on the `worktree` field. BOTH
|
|
219
|
+
# SIDES CARRY BOTH PATH FORMS: the dispatcher records the RESOLVED worktree path
|
|
220
|
+
# (`realpath`) and git may report either, and a desk registered by its symlinked
|
|
221
|
+
# path must still be found by its real one. So the manifest's field is resolved
|
|
222
|
+
# too, and either form matches either.
|
|
223
|
+
#
|
|
224
|
+
# SEVERAL MANIFESTS IS NOT THE FIRST MATCH. Two agents on one desk is an estate
|
|
225
|
+
# defect, and returning the first hides it, so this returns non-zero — the same
|
|
226
|
+
# answer `deskManifest` gives as `several`, which every caller reads as *no
|
|
227
|
+
# manifest*.
|
|
228
|
+
#
|
|
229
|
+
# ABSENT IS NOT FALSE: a missing directory, an unreadable manifest and a desk
|
|
230
|
+
# that is gone all answer "no manifest" rather than failing loudly.
|
|
121
231
|
plot_manifest_for_worktree() { # $1=worktree → manifest path, or "" (non-zero)
|
|
122
|
-
local wt="$1" dir real f wt_field
|
|
232
|
+
local wt="$1" dir real f wt_field wt_real found='' count=0
|
|
123
233
|
[ -n "$wt" ] || return 1
|
|
124
234
|
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
235
|
+
# The directory: the caller's value, then the one resolved at source time, then
|
|
236
|
+
# a resolution from the WORKTREE's own main checkout — which covers a caller
|
|
237
|
+
# whose cwd is outside any repository.
|
|
238
|
+
dir="$PLOT_MANIFEST_DIR"
|
|
239
|
+
if [ -z "$dir" ]; then
|
|
240
|
+
local root
|
|
241
|
+
root=$(plot_main_checkout_of "$wt" 2>/dev/null) || root=''
|
|
242
|
+
[ -n "$root" ] || return 1
|
|
243
|
+
dir=$(plot_manifest_dir_for "$root")
|
|
133
244
|
fi
|
|
134
245
|
[ -d "$dir" ] || return 1
|
|
135
246
|
|
|
@@ -143,12 +254,19 @@ plot_manifest_for_worktree() { # $1=worktree → manifest path, or "" (non-zero)
|
|
|
143
254
|
# per line, so a grep-and-sed approach avoids parsing JSON in bash.
|
|
144
255
|
wt_field=$(grep -m1 '"worktree":' "$f" 2>/dev/null | sed 's/.*"worktree": *"\([^"]*\)".*/\1/')
|
|
145
256
|
[ -n "$wt_field" ] || continue
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
257
|
+
# The manifest's own realpath, for the symlinked-registration case. A field
|
|
258
|
+
# naming a desk that is gone resolves to nothing and matches on its text.
|
|
259
|
+
wt_real=$(cd "$wt_field" 2>/dev/null && pwd -P) || wt_real=""
|
|
260
|
+
if [ "$wt_field" = "$wt" ] || [ "$wt_field" = "$real" ] ||
|
|
261
|
+
{ [ -n "$wt_real" ] && { [ "$wt_real" = "$wt" ] || [ "$wt_real" = "$real" ]; }; }; then
|
|
262
|
+
found="$f"
|
|
263
|
+
count=$((count + 1))
|
|
149
264
|
fi
|
|
150
265
|
done
|
|
151
|
-
|
|
266
|
+
|
|
267
|
+
# Exactly one, or nothing. `several` is named by the count, not by a pick.
|
|
268
|
+
[ "$count" = 1 ] || return 1
|
|
269
|
+
printf '%s' "$found"
|
|
152
270
|
}
|
|
153
271
|
|
|
154
272
|
# Read pid and startedAt from a manifest → "pid\tstartedAt", or "" (non-zero).
|
|
@@ -389,7 +507,29 @@ plot_worker_blocked_file() { # $1=worktree → prints the marker's basename
|
|
|
389
507
|
plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers dropped
|
|
390
508
|
local wt="$1"
|
|
391
509
|
[ -n "$wt" ] && [ -d "$wt" ] || return 0
|
|
392
|
-
plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)"
|
|
510
|
+
plot_worker_dirty_filter "$(git -C "$wt" status --porcelain 2>/dev/null)" "$wt"
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
# Keep a file the estate writes into every desk out of `git status`.
|
|
514
|
+
#
|
|
515
|
+
# THROUGH THE CLONE'S `info/exclude`, never `.gitignore`: a `.gitignore` rule
|
|
516
|
+
# lives in the branch's own content, so a desk cut from an older branch would
|
|
517
|
+
# not see it, and `info/exclude` is per-repository and shared by every worktree.
|
|
518
|
+
# The directory and the file are CREATED when absent. Measured 2026-10-01
|
|
519
|
+
# (#1130): a clone with no `.git/info/exclude` kept `?? .metadata_never_index`
|
|
520
|
+
# in every free desk, and the AgentMonitor reported each waiting agent as
|
|
521
|
+
# `holds unlanded work`, because the writer skipped a file that did not exist.
|
|
522
|
+
#
|
|
523
|
+
# Best-effort: a desk that cannot take the line still works. Always returns 0.
|
|
524
|
+
plot_desk_exclude() { # $1=worktree $2=the exact line to exclude
|
|
525
|
+
local common excl
|
|
526
|
+
common=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || return 0
|
|
527
|
+
[ -n "$common" ] || return 0
|
|
528
|
+
case "$common" in /*) ;; *) common="$1/$common" ;; esac
|
|
529
|
+
excl="$common/info/exclude"
|
|
530
|
+
mkdir -p "$common/info" 2>/dev/null || return 0
|
|
531
|
+
grep -qxF "$2" "$excl" 2>/dev/null || printf '%s\n' "$2" >> "$excl" 2>/dev/null || true
|
|
532
|
+
return 0
|
|
393
533
|
}
|
|
394
534
|
|
|
395
535
|
# The same filter, over status output the CALLER already has.
|
|
@@ -406,17 +546,486 @@ plot_worker_dirty() { # $1=worktree → the dirty files, one per line, leftovers
|
|
|
406
546
|
# what counts as work on the floor, two ways of getting the input to it — which
|
|
407
547
|
# is the same one-computation-two-renderings split this file already draws for
|
|
408
548
|
# `plot_worker_state`.
|
|
409
|
-
plot_worker_dirty_filter() { # $1=`git status --porcelain` output → the real work
|
|
549
|
+
plot_worker_dirty_filter() { # $1=`git status --porcelain` output $2=worktree (optional) → the real work
|
|
550
|
+
local status="$1" wt="${2:-}"
|
|
551
|
+
|
|
552
|
+
# THE GENERATED BUNDLES ARE EXCUSED FIRST, WHILE THE LINE STILL CARRIES ITS
|
|
553
|
+
# XY PREFIX — `exclude_bundle_paths` reads column 4 on itself, the same cut
|
|
554
|
+
# the three exclusions below apply after it. Same rule as those three: `main`
|
|
555
|
+
# rebuilds and pushes every generated bundle (`bug/main-builds-its-bundles`,
|
|
556
|
+
# #1249), so a desk that rebuilt one locally to test holds nothing an agent
|
|
557
|
+
# put there. Only when `$wt` is given: the exclusion is read from THAT
|
|
558
|
+
# worktree's own `build.mjs`, and a caller holding only text with no
|
|
559
|
+
# worktree to read gets the three exclusions below alone, exactly as before
|
|
560
|
+
# this bundle existed.
|
|
561
|
+
if [ -n "$wt" ] && command -v exclude_bundle_paths >/dev/null 2>&1; then
|
|
562
|
+
status=$(printf '%s\n' "$status" | exclude_bundle_paths "$wt")
|
|
563
|
+
fi
|
|
564
|
+
|
|
410
565
|
# `--porcelain` is the STABLE format; `git status` prose is localised and
|
|
411
566
|
# reflows. Cut at column 4: the first three bytes are the XY status pair and a
|
|
412
567
|
# space, and a filename can contain spaces of its own.
|
|
413
|
-
printf '%s' "$
|
|
568
|
+
printf '%s' "$status" \
|
|
414
569
|
| cut -c4- \
|
|
415
570
|
| grep -vE "(^|/)$PLOT_WORKER_RECORD" \
|
|
416
571
|
| grep -vE "$PLOT_EDITOR_LEFTOVER" \
|
|
417
572
|
| grep -vE "$PLOT_TOOL_SCRATCH" || true
|
|
418
573
|
}
|
|
419
574
|
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
# THE ONE-SAMPLE `idle` RULE — the shell's half of a declared duplicate
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
#
|
|
579
|
+
# A DECLARED DUPLICATE OF `idleNow`, and the pair is held by
|
|
580
|
+
# `packages/domain/corpus/sample.corpus.test.ts`. `docs/shell-and-domain.md` §1
|
|
581
|
+
# settles which side of the cost rule this falls on: the rule is asked once per
|
|
582
|
+
# agent per pass, so a 39 ms `node` hop is paid by every agent on this machine
|
|
583
|
+
# forever. Neither side is authoritative — on a disagreement the branch stops,
|
|
584
|
+
# and adjusting either side to make the comparison pass is the one move
|
|
585
|
+
# forbidden.
|
|
586
|
+
#
|
|
587
|
+
# WHY IT LIVES HERE RATHER THAN IN THE MONITOR. Two callers read it: the
|
|
588
|
+
# WorkerMonitor sources this file today, and the loop's own watcher sources it
|
|
589
|
+
# too. One function, two readers, one answer — the same split this file was
|
|
590
|
+
# extracted to hold.
|
|
591
|
+
|
|
592
|
+
# Seconds since the newest thing in a desk's tree changed.
|
|
593
|
+
#
|
|
594
|
+
# THIS IS WHAT REPLACED A COMPARISON BETWEEN TWO PASSES. The two-sample rule
|
|
595
|
+
# asked *did the fingerprint change between pass N-1 and pass N*, which needs a
|
|
596
|
+
# process to hold pass N-1. This asks *how long since anything moved*, which the
|
|
597
|
+
# filesystem has been recording all along — and `at least the window` is a
|
|
598
|
+
# stronger statement than `unchanged across two passes 30 s apart`.
|
|
599
|
+
#
|
|
600
|
+
# THREE SOURCES, AND THE NEWEST OF THEM WINS:
|
|
601
|
+
#
|
|
602
|
+
# HEAD's committer time an agent that commits has plainly done something
|
|
603
|
+
# each dirty path's mtime an agent editing a file
|
|
604
|
+
# each dirty path's PARENT an add, a removal or a rename, which does not
|
|
605
|
+
# move any surviving file's own mtime
|
|
606
|
+
#
|
|
607
|
+
# THE DESK ROOT'S OWN MTIME IS NEVER READ, and that is the reading's one
|
|
608
|
+
# deliberate blind spot. The loop writes `.plot-worker.*` records into the desk
|
|
609
|
+
# root and replaces them (`plot-dispatch.sh`'s manifest `mv`), which moves the
|
|
610
|
+
# root directory's mtime. `plot_worker_dirty_filter` drops those records from
|
|
611
|
+
# the list, but a dirty path AT the root would still contribute its parent — the
|
|
612
|
+
# root — and the loop's own bookkeeping would then read as tree activity, so
|
|
613
|
+
# `idle` could never fire. A parent counts only BELOW the root; a root-level
|
|
614
|
+
# dirty path contributes its own mtime instead. The stated cost: a removal or a
|
|
615
|
+
# rename at the desk root alone does not move this number.
|
|
616
|
+
#
|
|
617
|
+
# `unreadable` WHERE THERE IS NO TREE TO READ, and that word travels to the
|
|
618
|
+
# verdict rather than being collapsed into a number. A failure to observe is not
|
|
619
|
+
# evidence of something to see; zero would read as *everything just moved* and
|
|
620
|
+
# a huge number as *nothing has moved in years*, and both are inventions.
|
|
621
|
+
plot_worker_tree_quiet_seconds() { # $1=worktree → seconds | unreadable
|
|
622
|
+
local wt="$1" status paths head_ct now newest
|
|
623
|
+
[ -n "$wt" ] && [ -d "$wt" ] || { printf 'unreadable'; return 0; }
|
|
624
|
+
|
|
625
|
+
# HEAD's committer time, which is the whole reading on a clean tree. A repo
|
|
626
|
+
# with no commit yet answers nothing and leaves the dirty paths to speak.
|
|
627
|
+
head_ct=$(git -C "$wt" log -1 --format=%ct 2>/dev/null)
|
|
628
|
+
case "$head_ct" in ''|*[!0-9]*) head_ct='' ;; esac
|
|
629
|
+
|
|
630
|
+
# `-uall` IS FOR THIS READING ONLY, AND IT IS A MEASUREMENT RATHER THAN A
|
|
631
|
+
# TIDINESS. Default porcelain collapses a wholly new untracked directory to
|
|
632
|
+
# one line, `?? brandnew/`, because once git knows the whole directory is
|
|
633
|
+
# untracked it stops descending — fine for a display, fatal for an mtime. A
|
|
634
|
+
# directory's mtime moves when an ENTRY is added or removed and not when a
|
|
635
|
+
# file inside it is written, so a file an agent is editing right now inside a
|
|
636
|
+
# directory it created earlier reads as untouched. Measured 2026-10-02: a
|
|
637
|
+
# directory aged 2 000 s holding a file 1 s old read `tree quiet: 2001`, which
|
|
638
|
+
# is a false `idle` on an agent mid-edit. `-uall` lists `brandnew/f.txt`, so
|
|
639
|
+
# the file's own mtime is read and its parent is read beside it.
|
|
640
|
+
#
|
|
641
|
+
# THE FINGERPRINT KEEPS THE DEFAULT, and the asymmetry is correct: it asked
|
|
642
|
+
# *did these path NAMES change*, and a collapsed directory's name changes when
|
|
643
|
+
# the directory appears. This asks *when did anything move*, which the name
|
|
644
|
+
# cannot answer. Recorded in the plan's Open Points rather than widened
|
|
645
|
+
# silently.
|
|
646
|
+
status=$(git -C "$wt" status --porcelain -uall 2>/dev/null)
|
|
647
|
+
|
|
648
|
+
# THE SAME FILTER THE FINGERPRINT USED, for the same reason: this script's own
|
|
649
|
+
# records and the monitor's findings file are not work an agent left, and a
|
|
650
|
+
# raw status would make the monitor watch itself.
|
|
651
|
+
paths=''
|
|
652
|
+
if command -v plot_worker_dirty_filter >/dev/null 2>&1; then
|
|
653
|
+
paths=$(plot_worker_dirty_filter "$status")
|
|
654
|
+
else
|
|
655
|
+
paths=$(printf '%s' "$status" | cut -c4-)
|
|
656
|
+
fi
|
|
657
|
+
|
|
658
|
+
# Every path to stat, one per line, absolute. Built in awk rather than a bash
|
|
659
|
+
# loop so a desk holding hundreds of dirty paths costs one fork.
|
|
660
|
+
#
|
|
661
|
+
# A RENAME ARRIVES AS `old -> new` and the NEW name is the one that exists.
|
|
662
|
+
# A path with unusual bytes arrives quoted by git; the quotes are stripped so
|
|
663
|
+
# `stat` sees the name, which is lossy for a true embedded quote and the
|
|
664
|
+
# alternative is parsing C escapes in awk.
|
|
665
|
+
#
|
|
666
|
+
# A DELETED PATH DOES NOT EXIST, so its own mtime is unreadable. It is still
|
|
667
|
+
# listed — `stat` simply says nothing for it — and its parent below the root
|
|
668
|
+
# carries the change, which is exactly what a removal moves.
|
|
669
|
+
local list
|
|
670
|
+
list=$(printf '%s\n' "$paths" | awk -v wt="$wt" '
|
|
671
|
+
{ line = $0 }
|
|
672
|
+
line == "" { next }
|
|
673
|
+
# A rename: take the destination, which is the name on disk now.
|
|
674
|
+
{
|
|
675
|
+
i = index(line, " -> ")
|
|
676
|
+
if (i > 0) line = substr(line, i + 4)
|
|
677
|
+
# Git quotes a path holding unusual bytes. Drop the quotes; the escapes
|
|
678
|
+
# inside are left as they are, and such a path simply reads unreadable.
|
|
679
|
+
if (substr(line, 1, 1) == "\"" && substr(line, length(line), 1) == "\"")
|
|
680
|
+
line = substr(line, 2, length(line) - 2)
|
|
681
|
+
if (line == "") next
|
|
682
|
+
print wt "/" line
|
|
683
|
+
# THE PARENT, BUT ONLY BELOW THE ROOT. A path with no `/` in it sits at
|
|
684
|
+
# the desk root, and the root is the directory the loop keeps touching.
|
|
685
|
+
if (index(line, "/") > 0) {
|
|
686
|
+
n = line
|
|
687
|
+
sub(/\/[^\/]*$/, "", n)
|
|
688
|
+
if (n != "" && n != ".") print wt "/" n
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
')
|
|
692
|
+
|
|
693
|
+
# ONE `stat` CALL OVER EVERY PATH, and the flavour is probed once against a
|
|
694
|
+
# directory that certainly exists.
|
|
695
|
+
#
|
|
696
|
+
# THE ORDER IS LOAD-BEARING AND CI MEASURED WHY. On Linux `stat -f` is not an
|
|
697
|
+
# unknown flag — it means FILESYSTEM info and it SUCCEEDS, printing
|
|
698
|
+
# `Namelen: 255 Type: ext2/ext3`, which a caller then subtracts from a clock
|
|
699
|
+
# (`plot-fleetctl.sh`, and two tests that passed on macOS). So GNU's own form
|
|
700
|
+
# is asked first, because GNU is the implementation that mis-parses the other's
|
|
701
|
+
# flag, and each answer is validated as digits rather than trusted.
|
|
702
|
+
newest="$head_ct"
|
|
703
|
+
if [ -n "$list" ]; then
|
|
704
|
+
local fmt='' mtimes
|
|
705
|
+
if [ -n "$(stat -c %Y "$wt" 2>/dev/null)" ]; then fmt='gnu'
|
|
706
|
+
elif [ -n "$(stat -f %m "$wt" 2>/dev/null)" ]; then fmt='bsd'
|
|
707
|
+
fi
|
|
708
|
+
if [ -n "$fmt" ]; then
|
|
709
|
+
# A missing path makes `stat` exit non-zero while still printing the
|
|
710
|
+
# others, so the exit code is deliberately not read.
|
|
711
|
+
if [ "$fmt" = 'gnu' ]; then
|
|
712
|
+
mtimes=$(printf '%s\n' "$list" | tr '\n' '\0' | xargs -0 stat -c %Y 2>/dev/null)
|
|
713
|
+
else
|
|
714
|
+
mtimes=$(printf '%s\n' "$list" | tr '\n' '\0' | xargs -0 stat -f %m 2>/dev/null)
|
|
715
|
+
fi
|
|
716
|
+
# The maximum, taken in awk: only lines that are entirely digits count, so
|
|
717
|
+
# a filesystem report or an error line cannot become a timestamp.
|
|
718
|
+
local max
|
|
719
|
+
max=$(printf '%s\n' "$mtimes" | awk '/^[0-9]+$/ { if ($0 > m) m = $0 } END { if (m != "") print m }')
|
|
720
|
+
if [ -n "$max" ]; then
|
|
721
|
+
if [ -z "$newest" ] || [ "$max" -gt "$newest" ] 2>/dev/null; then newest="$max"; fi
|
|
722
|
+
fi
|
|
723
|
+
fi
|
|
724
|
+
fi
|
|
725
|
+
|
|
726
|
+
# NO COMMIT AND NO READABLE PATH is no reading at all — a desk whose git
|
|
727
|
+
# directory cannot be read, or a worktree with no history yet and nothing on
|
|
728
|
+
# the floor. It is not a very long silence.
|
|
729
|
+
[ -n "$newest" ] || { printf 'unreadable'; return 0; }
|
|
730
|
+
|
|
731
|
+
now=$(date +%s)
|
|
732
|
+
local quiet=$(( now - newest ))
|
|
733
|
+
# A time in the future — clock skew across a mounted volume — reads as zero
|
|
734
|
+
# rather than negative. A comparison against a window would behave correctly
|
|
735
|
+
# by accident here and not elsewhere; clamping says what is meant.
|
|
736
|
+
[ "$quiet" -lt 0 ] && quiet=0
|
|
737
|
+
printf '%s' "$quiet"
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
# Is this desk idle, from ONE reading of it?
|
|
741
|
+
#
|
|
742
|
+
# THE SHELL'S COPY OF `idleNow`, argument for argument. Six readings in, one
|
|
743
|
+
# word out:
|
|
744
|
+
#
|
|
745
|
+
# $1 pid alive | dead | unrecorded
|
|
746
|
+
# $2 spoken 1 spoken | 0 not (a reading, never an absence)
|
|
747
|
+
# $3 silence seconds since the newest transcript line, or any non-number
|
|
748
|
+
# $4 activity the sampler's word: `working` vetoes, `idle` and `` do not
|
|
749
|
+
# $5 treeQuiet seconds since the newest tree change, or any non-number
|
|
750
|
+
# $6 commits yes | no | unanswerable
|
|
751
|
+
# $7 window seconds a duration must reach
|
|
752
|
+
#
|
|
753
|
+
# ONE WORD ON STDOUT AND EXIT 0, ALWAYS. A caller that tests only for empty
|
|
754
|
+
# output cannot tell `silent` from *the function was missing*, so read the exit
|
|
755
|
+
# code: this prints `idle` or `silent` and returns 0, and a shell that never
|
|
756
|
+
# sourced this file returns 127 having printed nothing.
|
|
757
|
+
#
|
|
758
|
+
# `≥ window`, NOT `>`. The window is where the question becomes worth asking, so
|
|
759
|
+
# a desk exactly at it is eligible — the same boundary `quiet -lt window → busy`
|
|
760
|
+
# draws from the other side.
|
|
761
|
+
#
|
|
762
|
+
# THE CPU IS A VETO AND NOT THE VERDICT. Only `working` refuses, because only
|
|
763
|
+
# `working` says something is running. `idle` (a frozen subtree clock) and ``
|
|
764
|
+
# (no child holding a clock at all) agree here: past the window, each is an
|
|
765
|
+
# agent that has stopped. That is the opposite of how the old CPU-snapshot rule
|
|
766
|
+
# read the empty answer, and deliberately so — this line is reached only after
|
|
767
|
+
# the window has already elapsed.
|
|
768
|
+
#
|
|
769
|
+
# NO `gone` ARM. A dead pid answers `silent`: the wrapper that starts the agent
|
|
770
|
+
# knows the instant it ends and publishes `gone` itself.
|
|
771
|
+
#
|
|
772
|
+
# AN UNREADABLE VALUE ANSWERS `silent`, every one of them. An `unrecorded` pid,
|
|
773
|
+
# an `unavailable` transcript, an `unreadable` tree and an `unanswerable` commit
|
|
774
|
+
# question each withhold the finding, because a failure to observe is not
|
|
775
|
+
# evidence of something to see.
|
|
776
|
+
plot_worker_idle_now() { # $1..$7 as above → idle | silent
|
|
777
|
+
local pid="$1" spoken="$2" silence="$3" activity="$4" tree="$5" commits="$6" window="$7"
|
|
778
|
+
|
|
779
|
+
[ "$pid" = 'alive' ] || { printf 'silent'; return 0; }
|
|
780
|
+
[ "$spoken" = '1' ] || { printf 'silent'; return 0; }
|
|
781
|
+
|
|
782
|
+
# A non-numeric duration is a reading that was not taken. `unavailable`,
|
|
783
|
+
# `unreadable` and an empty string all land here, and so would a filesystem
|
|
784
|
+
# report that slipped past the validation above.
|
|
785
|
+
case "$window" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
|
|
786
|
+
case "$silence" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
|
|
787
|
+
[ "$silence" -ge "$window" ] || { printf 'silent'; return 0; }
|
|
788
|
+
|
|
789
|
+
[ "$activity" = 'working' ] && { printf 'silent'; return 0; }
|
|
790
|
+
|
|
791
|
+
case "$tree" in ''|*[!0-9]*) printf 'silent'; return 0 ;; esac
|
|
792
|
+
[ "$tree" -ge "$window" ] || { printf 'silent'; return 0; }
|
|
793
|
+
|
|
794
|
+
[ "$commits" = 'yes' ] || { printf 'silent'; return 0; }
|
|
795
|
+
printf 'idle'
|
|
796
|
+
return 0
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
# ---------------------------------------------------------------------------
|
|
800
|
+
# THE WATCHER'S OWN PASS — the readings `plot_worker_idle_now` is asked about
|
|
801
|
+
# ---------------------------------------------------------------------------
|
|
802
|
+
#
|
|
803
|
+
# MOVED FROM `plot-worker-monitor.sh` RATHER THAN REWRITTEN. The WorkerMonitor
|
|
804
|
+
# process is gone (`bug/the-loop-reports-idle`): the loop's own watcher
|
|
805
|
+
# subshell calls these instead, so the readings move to the file the loop
|
|
806
|
+
# already sources rather than living in a script that no longer runs.
|
|
807
|
+
#
|
|
808
|
+
# `monitor_has_commits` IS MOVED, NOT CHANGED. The scope guard that made it
|
|
809
|
+
# untouchable in wave 1 still holds — the `-- .` pathspec and the
|
|
810
|
+
# `origin/<default>` resolution are copied verbatim, including the comment
|
|
811
|
+
# that explains why a dispatched branch's own claim commit must not count.
|
|
812
|
+
|
|
813
|
+
# The reset epoch a desk is waiting out, or nothing.
|
|
814
|
+
#
|
|
815
|
+
# THE LOOP WRITES THE FILE AND THIS ONLY READS IT. `.plot-worker.limited`
|
|
816
|
+
# carries the reset epoch, the same instant as ISO text, and the limit line;
|
|
817
|
+
# only the first field is read here, because the caller compares integers and
|
|
818
|
+
# never parses a date.
|
|
819
|
+
#
|
|
820
|
+
# NOTHING IS ANSWERED FOR AN ABSENT, EMPTY OR UNPARSEABLE FILE, and the caller
|
|
821
|
+
# reads that as *this desk is not waiting*. A record whose first field is not a
|
|
822
|
+
# number is the same answer as no record: a reading that cannot be made must
|
|
823
|
+
# not widen into a reason to hold a verdict back.
|
|
824
|
+
plot_worker_limited_reset() { # $1=worktree → epoch seconds | ""
|
|
825
|
+
local file="$1/.plot-worker.limited" reset
|
|
826
|
+
[ -n "$1" ] && [ -r "$file" ] || return 0
|
|
827
|
+
reset=$(cut -f1 < "$file" 2>/dev/null | head -n1)
|
|
828
|
+
case "$reset" in (''|*[!0-9]*) return 0 ;; esac
|
|
829
|
+
printf '%s' "$reset"
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
# Has THIS worker's conversation written yet? → 0 spoken | 1 unspoken | 2 no handle
|
|
833
|
+
#
|
|
834
|
+
# THE DESK-WIDE NUMBER CANNOT SAY. After a hop to a new branch the loop mints a
|
|
835
|
+
# fresh handle, and the new conversation has no transcript file until its first
|
|
836
|
+
# line. Until then the desk's newest file is the PREVIOUS slice's, and its
|
|
837
|
+
# silence is not this worker's. So the watcher asks the loop's own probe with
|
|
838
|
+
# the loop's own handle: one probe, two readers, one answer.
|
|
839
|
+
#
|
|
840
|
+
# THREE ANSWERS, AND THE THIRD IS NOT THE SECOND. `0` the handle's file exists,
|
|
841
|
+
# `1` it does not, `2` there is no handle to ask about. `plot_transcript_exists`
|
|
842
|
+
# reads *no handle* as *no file*, which suits `session_flag`; here it would
|
|
843
|
+
# make a hand-started watcher read every quiet worker as unspoken and disable
|
|
844
|
+
# `idle` silently (#1074). So the handle is checked here, before the probe.
|
|
845
|
+
plot_worker_conversation_spoken() { # $1=worktree → 0 spoken | 1 unspoken | 2 no handle
|
|
846
|
+
command -v session_handle >/dev/null 2>&1 || return 2
|
|
847
|
+
command -v plot_transcript_exists >/dev/null 2>&1 || return 2
|
|
848
|
+
local handle
|
|
849
|
+
handle=$(session_handle) || return 2
|
|
850
|
+
[ -n "$handle" ] || return 2
|
|
851
|
+
plot_transcript_exists "$1" "$handle" && return 0
|
|
852
|
+
return 1
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
# Are there commits on this branch yet? → 0 yes | 1 no | 2 unanswerable
|
|
856
|
+
#
|
|
857
|
+
# THE THIRD CONDITION ON `idle`, and the one that separates a stall from an
|
|
858
|
+
# agent still thinking about a hard first slice.
|
|
859
|
+
#
|
|
860
|
+
# COUNTED AGAINST THE LOCAL `origin/<default>` REF — never a fetch, because
|
|
861
|
+
# this reading makes no network call. And when there is no such ref the
|
|
862
|
+
# question is UNANSWERABLE, so this returns 2 and `idle` does not fire:
|
|
863
|
+
# counting against nothing would count the whole history from the root commit
|
|
864
|
+
# and read every branch in a remote-less repo as having committed.
|
|
865
|
+
plot_worker_has_commits() { # $1=worktree → 0 yes | 1 no | 2 unanswerable
|
|
866
|
+
[ -n "$1" ] && [ -d "$1" ] || return 2
|
|
867
|
+
local wt="$1" base n
|
|
868
|
+
base=$(git -C "$wt" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
|
|
869
|
+
[ -n "$base" ] || { git -C "$wt" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
|
|
870
|
+
[ -n "$base" ] || return 2
|
|
871
|
+
# COUNT THE AGENT'S WORK, NOT THE BRANCH'S COMMITS. `plot-dispatch.sh` writes
|
|
872
|
+
# `commit --allow-empty -m "plot: claim <branch>"` BEFORE the agent starts, so
|
|
873
|
+
# `$base..HEAD` is never zero on a dispatched branch and this condition could
|
|
874
|
+
# never refuse an `idle`. Measured 2026-08-30 (#538 red in CI): a worker
|
|
875
|
+
# burning CPU in `yes > /dev/null` was reported idle, because the one
|
|
876
|
+
# condition that could have saved it was satisfied by bookkeeping the agent
|
|
877
|
+
# did not do.
|
|
878
|
+
#
|
|
879
|
+
# The `-- .` pathspec is what does it: `rev-list` with a pathspec keeps only
|
|
880
|
+
# commits that TOUCHED A FILE, and the claim is empty by construction
|
|
881
|
+
# (`--allow-empty`). That is a property rather than a message match — a claim
|
|
882
|
+
# whose wording changes still reads as empty, and an agent committing an
|
|
883
|
+
# empty marker of its own is correctly not counted as work either.
|
|
884
|
+
n=$(git -C "$wt" rev-list --count "$base..HEAD" -- . 2>/dev/null) || return 2
|
|
885
|
+
case "$n" in ''|*[!0-9]*) return 2 ;; esac
|
|
886
|
+
[ "$n" -gt 0 ] && return 0
|
|
887
|
+
return 1
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
json_escape() { # $1 = raw → prints a JSON-safe string body
|
|
891
|
+
printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null \
|
|
892
|
+
|| printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
# Append one finding line to a desk's findings file, in the WorkerMonitor's own
|
|
896
|
+
# shape — the board's reader keys on this exact field set and this exact
|
|
897
|
+
# monitor name, and changing either would make an unbroken channel look broken.
|
|
898
|
+
#
|
|
899
|
+
# `since` AND `measuredAt` ARE DIFFERENT TIMES. `measuredAt` is when this
|
|
900
|
+
# reading was taken; `since` is when the finding first held. A finding that has
|
|
901
|
+
# held for twenty minutes and one taken twenty minutes ago are not the same
|
|
902
|
+
# fact, and an operator triaging a board needs the first.
|
|
903
|
+
plot_worker_publish_finding() { # $1=file $2=branch $3=worktree $4=finding $5=evidence $6=since
|
|
904
|
+
local file="$1" branch="$2" worktree="$3" finding="$4" evidence="$5" since="$6" now line
|
|
905
|
+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
906
|
+
line=$(printf '{"monitor":"%s","branch":"%s","worktree":"%s","finding":"%s","since":"%s","evidence":"%s","measuredAt":"%s"}' \
|
|
907
|
+
'WorkerMonitor' \
|
|
908
|
+
"$(json_escape "$branch")" \
|
|
909
|
+
"$(json_escape "$worktree")" \
|
|
910
|
+
"$(json_escape "$finding")" \
|
|
911
|
+
"${since:-$now}" \
|
|
912
|
+
"$(json_escape "$evidence")" \
|
|
913
|
+
"$now")
|
|
914
|
+
[ -n "$file" ] && printf '%s\n' "$line" >> "$file" 2>/dev/null
|
|
915
|
+
printf 'plot-watch %s\n' "$line"
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
# ONE PASS OF THE LOOP'S OWN WATCHER: take the six readings, ask
|
|
919
|
+
# `plot_worker_idle_now`, publish only on a change.
|
|
920
|
+
#
|
|
921
|
+
# THE PID IS ALWAYS `alive`. The caller is the loop's watcher subshell, started
|
|
922
|
+
# with the loop's own pid (`$_watch_loop_pid`); that pid is alive by
|
|
923
|
+
# construction for as long as the watcher runs, so there is no `monitor_pid_alive`
|
|
924
|
+
# reading here the way the old monitor needed one for a SEPARATE process it was
|
|
925
|
+
# watching.
|
|
926
|
+
#
|
|
927
|
+
# `$5` IS THE RACE THE PLAN DID NOT ANTICIPATE. A prompt started into a
|
|
928
|
+
# conversation whose transcript is already older than the window could read
|
|
929
|
+
# `idle` on its FIRST pass and be ended before the model ever answers — the
|
|
930
|
+
# clamp mirrors the usage-limit clamp's own shape: `silenceSeconds` is capped at
|
|
931
|
+
# the seconds the prompt has actually been running, so a desk cannot be judged
|
|
932
|
+
# quiet for longer than this prompt has existed.
|
|
933
|
+
#
|
|
934
|
+
# THE CHILD IS SAMPLED ON THE PID GIVEN, NEVER ON `$_watch_loop_pid` ITSELF.
|
|
935
|
+
# `plot_worker_activity` sums the pid's whole descendant subtree; the loop's own
|
|
936
|
+
# pid is the root the agent CLI hangs off, so the caller passes it explicitly
|
|
937
|
+
# rather than this function assuming which pid names the subject.
|
|
938
|
+
#
|
|
939
|
+
# PUBLISH AND SIGNAL ARE SEPARATE. This function only ever publishes; it is the
|
|
940
|
+
# caller's job to decide whether the PUBLISHED finding may end the worker — the
|
|
941
|
+
# flag gates that decision, not this one.
|
|
942
|
+
plot_worker_idle_watch_pass() { # $1=worktree $2=branch $3=findings-file $4=window $5=prompt_started_at $6=pid → exit 0 idle | 1 silent (also publishes)
|
|
943
|
+
local wt="$1" branch="$2" file="$3" window="$4" started_at="$5" pid="$6"
|
|
944
|
+
local silence spoken_rc spoken tree commits_rc commits activity finding evidence verdict
|
|
945
|
+
|
|
946
|
+
silence=$(plot_transcript_quiet_seconds "$wt" 2>/dev/null)
|
|
947
|
+
case "$silence" in
|
|
948
|
+
''|unavailable|*[!0-9]*) silence='' ;;
|
|
949
|
+
esac
|
|
950
|
+
|
|
951
|
+
# THE USAGE-LIMIT CLAMP, AHEAD OF THE WINDOW CHECK. Silence is measured from
|
|
952
|
+
# the later of the newest transcript line and the reset this desk waits for;
|
|
953
|
+
# while the reset is ahead of now the difference is negative, which clamps to
|
|
954
|
+
# 0 and reads as busy — the agent is doing exactly what it should.
|
|
955
|
+
local limited_until
|
|
956
|
+
limited_until=$(plot_worker_limited_reset "$wt")
|
|
957
|
+
if [ -n "$limited_until" ] && [ -n "$silence" ]; then
|
|
958
|
+
local since_reset
|
|
959
|
+
since_reset=$(( $(date +%s) - limited_until ))
|
|
960
|
+
[ "$since_reset" -lt 0 ] && since_reset=0
|
|
961
|
+
[ "$since_reset" -lt "$silence" ] && silence=$since_reset
|
|
962
|
+
fi
|
|
963
|
+
|
|
964
|
+
# THE RACE CLAMP. A prompt's transcript may be older than the window on the
|
|
965
|
+
# very first pass, because it is the PREVIOUS slice's silence, not this
|
|
966
|
+
# prompt's. Silence can never exceed how long this prompt has actually run.
|
|
967
|
+
case "$started_at" in
|
|
968
|
+
''|*[!0-9]*) ;;
|
|
969
|
+
*)
|
|
970
|
+
local ran
|
|
971
|
+
ran=$(( $(date +%s) - started_at ))
|
|
972
|
+
[ "$ran" -lt 0 ] && ran=0
|
|
973
|
+
if [ -n "$silence" ] && [ "$ran" -lt "$silence" ]; then silence=$ran; fi
|
|
974
|
+
;;
|
|
975
|
+
esac
|
|
976
|
+
|
|
977
|
+
plot_worker_conversation_spoken "$wt"; spoken_rc=$?
|
|
978
|
+
case "$spoken_rc" in
|
|
979
|
+
0) spoken=1 ;;
|
|
980
|
+
*) spoken=0 ;;
|
|
981
|
+
esac
|
|
982
|
+
|
|
983
|
+
activity=$(plot_worker_activity "$pid" 2>/dev/null)
|
|
984
|
+
|
|
985
|
+
tree=$(plot_worker_tree_quiet_seconds "$wt" 2>/dev/null)
|
|
986
|
+
|
|
987
|
+
plot_worker_has_commits "$wt"; commits_rc=$?
|
|
988
|
+
case "$commits_rc" in
|
|
989
|
+
0) commits='yes' ;;
|
|
990
|
+
1) commits='no' ;;
|
|
991
|
+
*) commits='unanswerable' ;;
|
|
992
|
+
esac
|
|
993
|
+
|
|
994
|
+
verdict=$(plot_worker_idle_now 'alive' "$spoken" "${silence:-unavailable}" "$activity" "$tree" "$commits" "$window")
|
|
995
|
+
|
|
996
|
+
finding=''
|
|
997
|
+
evidence=''
|
|
998
|
+
if [ "$verdict" = 'idle' ]; then
|
|
999
|
+
finding='idle'
|
|
1000
|
+
evidence="the agent's transcript has been silent for over ${window}s with no child process burning CPU behind it, nothing in its tree has moved for ${tree}s, and the branch already carries commits"
|
|
1001
|
+
fi
|
|
1002
|
+
|
|
1003
|
+
# PUBLISH ONLY ON A CHANGE, held in a variable of the CALLER's subshell —
|
|
1004
|
+
# named `PLOT_WATCH_PUBLISHED`/`PLOT_WATCH_SINCE` rather than local, because
|
|
1005
|
+
# this function is called repeatedly from the watcher's own `while` loop and
|
|
1006
|
+
# the state must survive between calls the way `monitor_pass`'s did.
|
|
1007
|
+
if [ "$finding" != "${PLOT_WATCH_PUBLISHED:-}" ]; then
|
|
1008
|
+
local now_iso
|
|
1009
|
+
now_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
1010
|
+
if [ -n "$finding" ]; then
|
|
1011
|
+
PLOT_WATCH_SINCE="$now_iso"
|
|
1012
|
+
plot_worker_publish_finding "$file" "$branch" "$wt" "$finding" "$evidence" "$PLOT_WATCH_SINCE"
|
|
1013
|
+
elif [ -n "${PLOT_WATCH_PUBLISHED:-}" ]; then
|
|
1014
|
+
PLOT_WATCH_SINCE="$now_iso"
|
|
1015
|
+
plot_worker_publish_finding "$file" "$branch" "$wt" 'clear' \
|
|
1016
|
+
"the ${PLOT_WATCH_PUBLISHED} finding no longer holds; the worker is measuring healthy again" "$PLOT_WATCH_SINCE"
|
|
1017
|
+
fi
|
|
1018
|
+
PLOT_WATCH_PUBLISHED="$finding"
|
|
1019
|
+
fi
|
|
1020
|
+
|
|
1021
|
+
# THE VERDICT IS THE EXIT CODE, NOT STDOUT. Stdout is reserved for the
|
|
1022
|
+
# publish line alone (`plot_worker_publish_finding`'s own `plot-watch …`
|
|
1023
|
+
# line), the same convention the old monitor's process stdout carried into
|
|
1024
|
+
# `.plot-worker.log` beside the agent's own output. A caller that needs the
|
|
1025
|
+
# word reads the exit code: 0 for `idle`, 1 for anything else.
|
|
1026
|
+
[ "$finding" = 'idle' ]
|
|
1027
|
+
}
|
|
1028
|
+
|
|
420
1029
|
# The total CPU time, in centiseconds, of a pid and every process descended from
|
|
421
1030
|
# it. Prints the number; prints `0` and returns non-zero when the pid names no
|
|
422
1031
|
# live process at all.
|