@plot-pm/board 0.14.0 → 0.14.1
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 +105 -105
- package/package.json +1 -1
- package/plot-dispatch.sh +328 -40
- package/plot-host.sh +189 -27
- package/plot-worker-state.sh +201 -2
package/plot-host.sh
CHANGED
|
@@ -834,6 +834,47 @@ jenkins_no_instance() {
|
|
|
834
834
|
# estimate for. This draws it one step earlier, for a connector that does not
|
|
835
835
|
# exist at all — and `resultOf` (`adapters/run-script.ts:211`) maps exit 4 onto
|
|
836
836
|
# `unaskable`, which is the word the build port already answers with.
|
|
837
|
+
# The Jenkins REST credential, read from where `jen` already stores it.
|
|
838
|
+
#
|
|
839
|
+
# NOT THE KEYCLOAK BEARER. `jen auth token` prints one and Jenkins answers it
|
|
840
|
+
# with an HTML login redirect — measured 2026-09-10, and it is the trap that
|
|
841
|
+
# made an earlier reading conclude the sha was unreachable. Jenkins' own API
|
|
842
|
+
# takes BASIC auth with a user and an API token, which `jen` keeps in the login
|
|
843
|
+
# keychain under service `jen`, accounts `jenkins-user:<host>` and
|
|
844
|
+
# `jenkins-token:<host>`.
|
|
845
|
+
#
|
|
846
|
+
# IT PRINTS `user:token` AND NOTHING ELSE, or nothing at all. The caller passes
|
|
847
|
+
# it straight to `curl -u`; no branch echoes it, and no failure path names it.
|
|
848
|
+
#
|
|
849
|
+
# EMPTY IS THE HONEST ANSWER on a machine with no `security` (Linux, CI), with
|
|
850
|
+
# no keychain entry, or with either half missing — a half credential is not a
|
|
851
|
+
# credential. The caller turns that into exit 4, which is *cannot be asked*.
|
|
852
|
+
jenkins_rest_credential() { # $1 = the bare host
|
|
853
|
+
command -v security >/dev/null 2>&1 || return 1
|
|
854
|
+
local u t
|
|
855
|
+
u=$(security find-generic-password -s jen -a "jenkins-user:$1" -w 2>/dev/null) || return 1
|
|
856
|
+
t=$(security find-generic-password -s jen -a "jenkins-token:$1" -w 2>/dev/null) || return 1
|
|
857
|
+
[ -n "$u" ] && [ -n "$t" ] || return 1
|
|
858
|
+
printf '%s:%s' "$u" "$t"
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
# A job path as Jenkins' REST API spells it: every segment under `job/`.
|
|
862
|
+
#
|
|
863
|
+
# `quaweb/release` is the path a person writes and `job/quaweb/job/release` is
|
|
864
|
+
# the URL, because a Jenkins folder is itself a job. A multibranch branch adds
|
|
865
|
+
# one more segment, PERCENT-ENCODED — `bug/foo` is `bug%2Ffoo`, the same
|
|
866
|
+
# encoding `jenkins_build_map` already decodes on the way back.
|
|
867
|
+
jenkins_job_url_path() { # $1 = job path, $2 = branch or ""
|
|
868
|
+
local out="" seg
|
|
869
|
+
local IFS=/
|
|
870
|
+
for seg in $1; do [ -n "$seg" ] && out="$out/job/$seg"; done
|
|
871
|
+
unset IFS
|
|
872
|
+
if [ -n "${2:-}" ]; then
|
|
873
|
+
out="$out/job/$(printf '%s' "$2" | sed 's|/|%2F|g')"
|
|
874
|
+
fi
|
|
875
|
+
printf '%s' "$out"
|
|
876
|
+
}
|
|
877
|
+
|
|
837
878
|
ci_unaskable() { # $1 = the op's name, $2 = the CI word (may be empty)
|
|
838
879
|
local ci_word="${2:-}"
|
|
839
880
|
if [ -z "$ci_word" ] || [ "$ci_word" = none ]; then
|
|
@@ -1722,12 +1763,47 @@ backend_declared() {
|
|
|
1722
1763
|
return
|
|
1723
1764
|
fi
|
|
1724
1765
|
local v
|
|
1725
|
-
v="$(bash "$here/plot-config.sh" get "Git host" "
|
|
1766
|
+
v="$(bash "$here/plot-config.sh" get "Git host" "" | tr '[:upper:]' '[:lower:]')"
|
|
1726
1767
|
case "$v" in
|
|
1727
|
-
bb) echo "bitbucket" ;;
|
|
1728
|
-
"")
|
|
1729
|
-
*) printf '%s\n' "$v" ;;
|
|
1768
|
+
bb) echo "bitbucket"; return ;;
|
|
1769
|
+
"") : ;;
|
|
1770
|
+
*) printf '%s\n' "$v"; return ;;
|
|
1730
1771
|
esac
|
|
1772
|
+
|
|
1773
|
+
# NOTHING DECLARED — INFER FROM THE REMOTE, AND REFUSE WHERE THERE IS NONE.
|
|
1774
|
+
# This answered `github` unconditionally until 2026-09-11, which is the
|
|
1775
|
+
# reassuring direction: a Bitbucket repo that forgot the key got GitHub's
|
|
1776
|
+
# answer, and a repo with no remote at all got one too. Measured on a fresh
|
|
1777
|
+
# `git init` with no remote: `backend` printed `github` and exited 0.
|
|
1778
|
+
#
|
|
1779
|
+
# THE REMOTE IS EVIDENCE AND THE KEY IS A DECLARATION, so the key still wins
|
|
1780
|
+
# above. What changes is only the case where nobody said: a hostname in the
|
|
1781
|
+
# remote is a reading, and no remote is no reading.
|
|
1782
|
+
local url
|
|
1783
|
+
url="$(git config --get remote.origin.url 2>/dev/null || true)"
|
|
1784
|
+
case "$url" in
|
|
1785
|
+
*github.com*) echo "github"; return ;;
|
|
1786
|
+
*bitbucket.org*) echo "bitbucket"; return ;;
|
|
1787
|
+
esac
|
|
1788
|
+
|
|
1789
|
+
# NOTHING NAMES A HOST, AND THAT IS REPORTED RATHER THAN REFUSED. An earlier
|
|
1790
|
+
# version of this returned exit 4 here, and five contract tests went red:
|
|
1791
|
+
# a sandbox repository with no remote is a legitimate, common shape — six
|
|
1792
|
+
# suites build one — and every op that needs a host in one was relying on
|
|
1793
|
+
# this default. Refusing at the resolver punishes them for a question they
|
|
1794
|
+
# never asked.
|
|
1795
|
+
#
|
|
1796
|
+
# SO THE GUESS SURVIVES AND STOPS BEING SILENT — AND IT SIGNALS THROUGH THE
|
|
1797
|
+
# EXIT CODE, NEVER A VARIABLE. This set a `BACKEND_UNNAMED` global first, and
|
|
1798
|
+
# the caller reads it as `v="$(backend_declared)"` — a COMMAND SUBSTITUTION,
|
|
1799
|
+
# which runs in a subshell, so the assignment died with the child and the
|
|
1800
|
+
# parent always read 0. The warning never printed, and a direct call looked
|
|
1801
|
+
# correct because its stdout was right.
|
|
1802
|
+
#
|
|
1803
|
+
# Exit 9 is arbitrary and deliberately outside the contract's 0/1/3/4: it
|
|
1804
|
+
# never leaves this file, and `backend` maps it back to a successful answer.
|
|
1805
|
+
echo "github"
|
|
1806
|
+
return 9
|
|
1731
1807
|
}
|
|
1732
1808
|
|
|
1733
1809
|
# The resolved backend, refused where this script has no arm for it.
|
|
@@ -1740,12 +1816,27 @@ backend_declared() {
|
|
|
1740
1816
|
# person must fix. `host-shell.ts` reads that code as `unaskable` and reads the
|
|
1741
1817
|
# sentence below for the name.
|
|
1742
1818
|
backend() {
|
|
1743
|
-
local v
|
|
1744
|
-
|
|
1819
|
+
local v rc unnamed=0
|
|
1820
|
+
# THE DECLARED-HOST REFUSAL IS PASSED THROUGH, NOT FLATTENED. `|| return 1`
|
|
1821
|
+
# collapsed exit 4 into 1 here, and 4 is the one code every caller reads as
|
|
1822
|
+
# *this cannot be asked at all* rather than *retry*. Measured 2026-09-11: a
|
|
1823
|
+
# repository with no remote exited 1, which tells a caller to try again.
|
|
1824
|
+
v="$(backend_declared)"; rc=$?
|
|
1825
|
+
# 9 is the resolver's private word for *answered, but nothing named it*.
|
|
1826
|
+
if [ "$rc" -eq 9 ]; then unnamed=1; rc=0; fi
|
|
1827
|
+
[ "$rc" -eq 0 ] || return "$rc"
|
|
1745
1828
|
if ! host_drivable "$v"; then
|
|
1746
1829
|
echo "plot-host: cannot drive '$v' — this script drives ${HOST_DRIVES// /, }; set the 'Git host' key in CLAUDE.md (or \$PLOT_HOST) to one of them" >&2
|
|
1747
1830
|
return 4
|
|
1748
1831
|
fi
|
|
1832
|
+
# THE ANSWER IS PRINTED EITHER WAY, AND A GUESS SAYS SO. `backend` is a
|
|
1833
|
+
# reading, not a gate: a caller that needs a host still gets one, and a
|
|
1834
|
+
# person asking which host this is learns the answer was inferred from
|
|
1835
|
+
# nothing. Exit stays 0 — the value is usable, its provenance is not certain.
|
|
1836
|
+
if [ "$unnamed" = 1 ]; then
|
|
1837
|
+
echo "plot-host: no 'Git host' key and no remote names one — assuming '$v'" >&2
|
|
1838
|
+
echo " Set the 'Git host' key in ## Plot Config (or \$PLOT_HOST) to be sure." >&2
|
|
1839
|
+
fi
|
|
1749
1840
|
printf '%s\n' "$v"
|
|
1750
1841
|
}
|
|
1751
1842
|
|
|
@@ -2991,29 +3082,100 @@ case "$op" in
|
|
|
2991
3082
|
_ci="$(ci_scheme)"
|
|
2992
3083
|
case "$_ci" in
|
|
2993
3084
|
jenkins)
|
|
2994
|
-
#
|
|
2995
|
-
#
|
|
2996
|
-
#
|
|
2997
|
-
#
|
|
2998
|
-
#
|
|
3085
|
+
# THE SHA IS IN JENKINS AND `jen` IS NOT THE TRANSPORT. Measured
|
|
3086
|
+
# 2026-09-10 against a live instance: a build entry from `jen build
|
|
3087
|
+
# list --json` carries `id`, `status`, timings and stages, and a search
|
|
3088
|
+
# of the whole payload for `sha|commit|revision|scm` matches nothing.
|
|
3089
|
+
# `jen` has no changesets subcommand and no raw-API passthrough.
|
|
3090
|
+
#
|
|
3091
|
+
# JENKINS' OWN REST API ANSWERS IT, at
|
|
3092
|
+
# `actions[].lastBuiltRevision.SHA1`, paired with the branch. One
|
|
3093
|
+
# `tree=` query returns a whole history — 4855 bytes for five builds —
|
|
3094
|
+
# so this costs one round trip like the GitHub arm does.
|
|
3095
|
+
#
|
|
3096
|
+
# THIS ARM EXITED 4 UNTIL 2026-09-11, and the refusal was honest for
|
|
3097
|
+
# the transport it had. What changed is the transport, not the rule.
|
|
3098
|
+
_jen_instance="$(jenkins_instance)"
|
|
3099
|
+
[ -n "$_jen_instance" ] || jenkins_no_instance
|
|
3100
|
+
_jen_host="${_jen_instance%%/*}"
|
|
3101
|
+
_jen_job="${_jen_instance#*/}"
|
|
3102
|
+
[ "$_jen_job" = "$_jen_instance" ] && _jen_job=""
|
|
3103
|
+
[ -n "${PLOT_JENKINS_JOB:-}" ] && _jen_job="$PLOT_JENKINS_JOB"
|
|
3104
|
+
if [ -z "$_jen_job" ]; then
|
|
3105
|
+
# A bare-host instance names no job, and a sha lives in a job's
|
|
3106
|
+
# builds. `runs` degrades to an empty map here; this op has no row to
|
|
3107
|
+
# carry that, so the only way to say *cannot be asked* is exit 4.
|
|
3108
|
+
echo "plot-host: run-for-sha — the Jenkins instance names no job path" >&2
|
|
3109
|
+
echo " A sha is a fact about a job's builds, so the instance must be" >&2
|
|
3110
|
+
echo " <slug>/<job/path> rather than a bare host." >&2
|
|
3111
|
+
exit 4
|
|
3112
|
+
fi
|
|
3113
|
+
_jen_cred="$(jenkins_rest_credential "$_jen_host")" || {
|
|
3114
|
+
# NO CREDENTIAL IS *CANNOT BE ASKED*, never *no run*. The keychain is
|
|
3115
|
+
# macOS-only and a Linux agent legitimately has none; saying nothing
|
|
3116
|
+
# at exit 0 would read as a branch that never built.
|
|
3117
|
+
echo "plot-host: run-for-sha — no Jenkins API credential for '$_jen_host'" >&2
|
|
3118
|
+
echo " Jenkins' REST API takes basic auth with an API token, which" >&2
|
|
3119
|
+
echo " \`jen auth login\` stores in the login keychain. The Keycloak" >&2
|
|
3120
|
+
echo " bearer from \`jen auth token\` is NOT it — Jenkins answers that" >&2
|
|
3121
|
+
echo " with a login redirect." >&2
|
|
3122
|
+
exit 4
|
|
3123
|
+
}
|
|
3124
|
+
# A BRANCH IS A JOB SEGMENT ON A MULTIBRANCH JOB AND NOT ON A PLAIN
|
|
3125
|
+
# ONE, and no reading of the configured path says which this is. So the
|
|
3126
|
+
# multibranch URL is tried first and the plain one is the fallback:
|
|
3127
|
+
# `job/quaweb/job/continuous-build/job/content%2Fctas` against
|
|
3128
|
+
# `job/quaweb/job/release`. Measured 2026-09-11 — asking the plain job
|
|
3129
|
+
# for a branch segment answers 404, which is why guessing one shape
|
|
3130
|
+
# cost a run.
|
|
2999
3131
|
#
|
|
3000
|
-
# THE
|
|
3001
|
-
#
|
|
3002
|
-
# `
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3132
|
+
# THE PLAIN JOB REPORTS ITS OWN BUILDS whatever branch was asked about,
|
|
3133
|
+
# and that is honest rather than wrong: a pipeline job builds one
|
|
3134
|
+
# thing, and the `sha` in the answer says which commit it built.
|
|
3135
|
+
_jen_tree="tree=builds%5Bnumber,result,building,timestamp,url,actions%5BlastBuiltRevision%5BSHA1%5D%5D%5D%7B0,$limit%7D"
|
|
3136
|
+
_jen_body=""
|
|
3137
|
+
for _jen_path in \
|
|
3138
|
+
"$(jenkins_job_url_path "$_jen_job" "$branch")" \
|
|
3139
|
+
"$(jenkins_job_url_path "$_jen_job" "")"; do
|
|
3140
|
+
host_slot_take jenkins ''
|
|
3141
|
+
_jen_try=$(curl -sg --max-time 30 -u "$_jen_cred" \
|
|
3142
|
+
"https://$_jen_host$_jen_path/api/json?$_jen_tree" 2>/dev/null) || true
|
|
3143
|
+
host_slot_give
|
|
3144
|
+
budget_record_call jenkins ''
|
|
3145
|
+
if printf '%s' "$_jen_try" | jq -e 'has("builds")' >/dev/null 2>&1; then
|
|
3146
|
+
_jen_body="$_jen_try"; break
|
|
3147
|
+
fi
|
|
3148
|
+
done
|
|
3149
|
+
_jen_cred=""
|
|
3150
|
+
if [ -z "$_jen_body" ] || ! printf '%s' "$_jen_body" | jq -e 'has("builds")' >/dev/null 2>&1; then
|
|
3151
|
+
# AN UNREACHABLE INSTANCE IS NOT AN EMPTY HISTORY. A refused or
|
|
3152
|
+
# redirected request answers HTML, which fails the `builds` test.
|
|
3153
|
+
echo "plot-host: run-for-sha — Jenkins did not answer for '$_jen_host'" >&2
|
|
3154
|
+
exit 4
|
|
3155
|
+
fi
|
|
3156
|
+
# THE SAME FALLBACK RULE AS THE GITHUB ARM, and it is inherited rather
|
|
3157
|
+
# than invented: the asked-for sha if a build carries it, else the
|
|
3158
|
+
# newest build, and `sha` says WHICH. A caller that could not tell the
|
|
3159
|
+
# two apart would be back to the branch-scoped guessing this op ends.
|
|
3006
3160
|
#
|
|
3007
|
-
#
|
|
3008
|
-
#
|
|
3009
|
-
#
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3161
|
+
# `result` is null while a build runs, which is Jenkins' own word for
|
|
3162
|
+
# *in flight* — mapped to the `status`/`conclusion` split the contract
|
|
3163
|
+
# already uses, so the shape does not fork per connector.
|
|
3164
|
+
printf '%s' "$_jen_body" | jq -c --arg sha "$sha" '
|
|
3165
|
+
[ .builds[]
|
|
3166
|
+
| { sha: ([ .actions[]? | select(.lastBuiltRevision) | .lastBuiltRevision.SHA1 ] | first // ""),
|
|
3167
|
+
status: (if .building then "in_progress" else "completed" end),
|
|
3168
|
+
conclusion: (if .building then null else (.result // null) end),
|
|
3169
|
+
url: (.url // ""),
|
|
3170
|
+
startedAt: (if .timestamp then (.timestamp / 1000 | todate) else "" end) } ]
|
|
3171
|
+
| ((map(select(.sha == $sha)) | .[0]) // .[0])
|
|
3172
|
+
| select(. != null)' 2>/dev/null || true
|
|
3173
|
+
# THIS ARM ANSWERS AND THE OP IS OVER. Everything below the `esac` is
|
|
3174
|
+
# the GitHub path — the old jenkins arm reached it only because it
|
|
3175
|
+
# ended in `exit 4`. Measured 2026-09-11: without this the answer was
|
|
3176
|
+
# printed and then a `CI is github-actions but the git host is
|
|
3177
|
+
# 'bitbucket'` refusal followed it on stderr.
|
|
3178
|
+
exit 0
|
|
3017
3179
|
;;
|
|
3018
3180
|
github-actions) : ;;
|
|
3019
3181
|
*) ci_unaskable run-for-sha "$_ci" ;;
|
package/plot-worker-state.sh
CHANGED
|
@@ -489,6 +489,140 @@ plot_worker_cpu_centis() { # $1=pid → total CPU centiseconds of pid+descendant
|
|
|
489
489
|
}'
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
# The name a live agent's process carries, as a substring of its command.
|
|
493
|
+
#
|
|
494
|
+
# CONFIGURABLE BECAUSE THE AGENT IS THE PROJECT'S, NOT PLOT'S. Principle 5 —
|
|
495
|
+
# Plot hardcodes no tooling — and the `Worker command` key already says every
|
|
496
|
+
# project names its own. This is the default because it is what this estate
|
|
497
|
+
# runs; a project whose agent is a different binary sets `PLOT_AGENT_PROCESS`
|
|
498
|
+
# and nothing else changes.
|
|
499
|
+
: "${PLOT_AGENT_PROCESS:=claude}"
|
|
500
|
+
|
|
501
|
+
# Whether an AGENT — not merely the wrapper — is alive under this pid.
|
|
502
|
+
#
|
|
503
|
+
# THE DEFECT THIS ANSWERS. The pid this fleet records is the loop shell, and
|
|
504
|
+
# `kill -0` on it succeeds for the whole `Worker bound` (28800 s) whether or not
|
|
505
|
+
# an agent still runs inside it. Measured 2026-09-11, in one session: FOUR
|
|
506
|
+
# agents ended mid-slice, none failed a build, none wrote a marker, and every
|
|
507
|
+
# one reported `running` with a plausible quiet time. Three left 8 commits and 9
|
|
508
|
+
# uncommitted files on their desks — one step from done, and all three would
|
|
509
|
+
# have been reaped as abandoned.
|
|
510
|
+
#
|
|
511
|
+
# EXISTENCE, NOT MOTION, AND THAT IS THE WHOLE DISTINCTION FROM THE CUE BELOW.
|
|
512
|
+
# `plot_worker_activity` samples CPU twice and asks whether the subtree is
|
|
513
|
+
# MOVING; an agent blocked on a network read is `idle` and perfectly alive. This
|
|
514
|
+
# asks whether there is an agent in the subtree AT ALL, which is a set
|
|
515
|
+
# membership test over one snapshot rather than a delta over two. The cue may
|
|
516
|
+
# never decide liveness, and this may never be read as a cue.
|
|
517
|
+
#
|
|
518
|
+
# ACCUMULATED CPU CANNOT DECIDE IT EITHER. Measured 2026-09-12 on this machine,
|
|
519
|
+
# a dead agent's tree held three `bash` children at 0:41.50, 0:41.22 and
|
|
520
|
+
# 0:40.31 — forty seconds of CPU each, burnt before the agent died. Only the
|
|
521
|
+
# PRESENCE of the agent process separates that tree from a live one.
|
|
522
|
+
#
|
|
523
|
+
# THE SAME ONE-SNAPSHOT WALK `plot_worker_cpu_centis` USES, and for its reason:
|
|
524
|
+
# a `pgrep -P` recursion forks a process per descendant on a scan the board
|
|
525
|
+
# polls every 5 s. It is also the only shape that reaches the real depth —
|
|
526
|
+
# measured on a healthy agent the same day, `claude` sat THREE levels below the
|
|
527
|
+
# recorded pid (`sh` → `bash` → `bash` → `claude`), so a depth-limited probe
|
|
528
|
+
# reports every live agent absent.
|
|
529
|
+
#
|
|
530
|
+
# ABSENT IS NOT FALSE, the rule this file keeps re-learning. A pid naming no
|
|
531
|
+
# process at all returns 2 — *could not look* — rather than 1, because a failure
|
|
532
|
+
# to observe is not evidence of something to see. The caller has already
|
|
533
|
+
# established the pid answers `kill -0` before it asks this.
|
|
534
|
+
# How long a wrapper must have been alive before its empty subtree means
|
|
535
|
+
# anything.
|
|
536
|
+
#
|
|
537
|
+
# THE STARTUP WINDOW IS REAL AND IT IS THIS READING'S OWN. `kill -0` succeeds
|
|
538
|
+
# the instant the wrapper exists, and the agent is forked some time after that —
|
|
539
|
+
# measured 2026-09-12, 37 to 237 ms for a `sh` forking a trivial child, and a
|
|
540
|
+
# real agent boots in seconds. A reading taken inside that window sees a worker
|
|
541
|
+
# that is STARTING and would call it stopped.
|
|
542
|
+
#
|
|
543
|
+
# The file already documents the same hazard one level down: *"There is a
|
|
544
|
+
# sub-millisecond window after the wrapper starts and before `.plot-worker.pid`
|
|
545
|
+
# is written, and a scan landing in it reads `none` — honest."* That window is
|
|
546
|
+
# tolerable because `none` tells a reader to look again; this one is not, because
|
|
547
|
+
# the whole point of the reading is to let something ACT on a stopped agent, and
|
|
548
|
+
# acting on a starting one hands its desk away as it boots.
|
|
549
|
+
#
|
|
550
|
+
# THIRTY SECONDS, AND IT IS A GUESS SAID OUT LOUD. Nothing has measured how long
|
|
551
|
+
# an agent takes to appear under its wrapper on a loaded machine; this is an
|
|
552
|
+
# order of magnitude above the worst observed fork and an order below the
|
|
553
|
+
# shortest slice. It is overridable so the tests need not wait, and a project on
|
|
554
|
+
# slower hardware can raise it.
|
|
555
|
+
: "${PLOT_AGENT_GRACE_SECONDS:=30}"
|
|
556
|
+
|
|
557
|
+
# The whole seconds a pid has been alive, or empty when it cannot be read.
|
|
558
|
+
#
|
|
559
|
+
# `etime` RATHER THAN `lstart`, because this needs a DURATION and `lstart` is a
|
|
560
|
+
# date a caller would have to parse and subtract. `[[DD-]HH:]MM:SS` is parsed
|
|
561
|
+
# from the right, the way `plot_worker_cpu_centis` parses its clock and for the
|
|
562
|
+
# same reason: an absolute-seconds assumption wraps at 60.
|
|
563
|
+
plot_pid_elapsed_seconds() { # $1=pid → whole seconds, or empty
|
|
564
|
+
local pid="$1" raw
|
|
565
|
+
[ -n "$pid" ] || return 1
|
|
566
|
+
raw=$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ') || return 1
|
|
567
|
+
[ -n "$raw" ] || return 1
|
|
568
|
+
printf '%s' "$raw" | awk '
|
|
569
|
+
{
|
|
570
|
+
n = split($0, dh, "-")
|
|
571
|
+
days = (n == 2) ? dh[1] : 0
|
|
572
|
+
t = (n == 2) ? dh[2] : dh[1]
|
|
573
|
+
m = split(t, p, ":")
|
|
574
|
+
total = 0; mult = 1
|
|
575
|
+
for (i = m; i >= 1; i--) { total += p[i] * mult; mult *= 60 }
|
|
576
|
+
print total + (days * 86400)
|
|
577
|
+
}'
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
plot_worker_agent_alive() { # $1=pid → 0 agent present, 1 absent, 2 unaskable
|
|
581
|
+
local root="$1" age
|
|
582
|
+
[ -n "$root" ] || return 2
|
|
583
|
+
case "$root" in *[!0-9]*) return 2 ;; esac
|
|
584
|
+
|
|
585
|
+
# A WRAPPER YOUNGER THAN THE GRACE IS UNASKABLE, NEVER ABSENT. It may be
|
|
586
|
+
# starting its agent right now, and `2` is the answer that says *could not
|
|
587
|
+
# look* — which the callers already resolve to today's behaviour. Absent is
|
|
588
|
+
# not false, and a failure to observe is not evidence of something to see.
|
|
589
|
+
age=$(plot_pid_elapsed_seconds "$root") || age=""
|
|
590
|
+
if [ -n "$age" ] && [ "$age" -lt "$PLOT_AGENT_GRACE_SECONDS" ] 2>/dev/null; then
|
|
591
|
+
return 2
|
|
592
|
+
fi
|
|
593
|
+
|
|
594
|
+
# `comm=` IS THE EXECUTABLE, `command=` IS THE WHOLE INVOCATION, and this
|
|
595
|
+
# needs the second. A wrapper whose argv merely NAMES the agent would match on
|
|
596
|
+
# `command=`, so the match is anchored to the executable's own basename —
|
|
597
|
+
# taken from `comm=`, which macOS truncates but never rewrites.
|
|
598
|
+
ps -o pid=,ppid=,comm= -ax 2>/dev/null | awk -v root="$root" -v want="$PLOT_AGENT_PROCESS" '
|
|
599
|
+
{ pid[$1] = $1; ppid[$1] = $2
|
|
600
|
+
# Everything after pid and ppid is the command; keep its basename.
|
|
601
|
+
c = $0; sub(/^[ \t]*[0-9]+[ \t]+[0-9]+[ \t]+/, "", c)
|
|
602
|
+
sub(/.*\//, "", c)
|
|
603
|
+
comm[$1] = c }
|
|
604
|
+
END {
|
|
605
|
+
if (!(root in pid)) { exit 2 }
|
|
606
|
+
# The same relaxation the CPU walker uses: sweep the ppid map until the
|
|
607
|
+
# subtree stops growing. Depth is unbounded, which is the point.
|
|
608
|
+
inset[root] = 1
|
|
609
|
+
changed = 1
|
|
610
|
+
while (changed) {
|
|
611
|
+
changed = 0
|
|
612
|
+
for (p in ppid) {
|
|
613
|
+
if (!(p in inset) && (ppid[p] in inset)) { inset[p] = 1; changed = 1 }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
# THE ROOT ITSELF IS EXCLUDED. The recorded pid is the loop shell by
|
|
617
|
+
# construction, and a shell that matched would make every desk read alive.
|
|
618
|
+
for (p in inset) {
|
|
619
|
+
if (p == root) continue
|
|
620
|
+
if (index(comm[p], want) > 0) { exit 0 }
|
|
621
|
+
}
|
|
622
|
+
exit 1
|
|
623
|
+
}'
|
|
624
|
+
}
|
|
625
|
+
|
|
492
626
|
# Whether a RUNNING worker's child is doing work — `working`, `idle`, or "".
|
|
493
627
|
#
|
|
494
628
|
# A CUE, NOT A STATE. The row already reads `running`; this is the secondary
|
|
@@ -691,7 +825,54 @@ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
|
|
|
691
825
|
return
|
|
692
826
|
fi
|
|
693
827
|
fi
|
|
694
|
-
#
|
|
828
|
+
# THE WRAPPER LIVES. DOES AN AGENT? `kill -0` answered about the loop shell,
|
|
829
|
+
# and that shell outlives its agent for the whole `Worker bound` — measured
|
|
830
|
+
# 2026-09-11, four agents ended mid-slice and every one reported `running`.
|
|
831
|
+
# So the pid answering is a precondition for this question, never the answer
|
|
832
|
+
# to it.
|
|
833
|
+
#
|
|
834
|
+
# ONLY A DEFINITE ABSENCE MOVES THE READING. Return 2 is *could not look* —
|
|
835
|
+
# a pid that vanished between `kill -0` and here — and it falls through to
|
|
836
|
+
# `running`, which is what this reported before the reading existed. Absent
|
|
837
|
+
# is not false.
|
|
838
|
+
#
|
|
839
|
+
# AND THE QUESTION IS ONLY ASKED OF A DESK PLOT LAUNCHED A WORKER INTO,
|
|
840
|
+
# which `.plot-worker.wrapper.pid` is the proof of. A recorded pid with no
|
|
841
|
+
# wrapper file beside it was never started by `start_worker` — a hand-made
|
|
842
|
+
# desk, or a fixture writing a pid by hand — so Plot has no grounds to
|
|
843
|
+
# expect an agent beneath it, and asking would reinterpret every such pid as
|
|
844
|
+
# an orphan.
|
|
845
|
+
#
|
|
846
|
+
# Measured 2026-09-12 in CI: `--status` reported `finished` for a desk whose
|
|
847
|
+
# recorded pid was the TEST RUNNER — alive 1436 s, with no agent beneath it.
|
|
848
|
+
# The grace window cannot catch that, because the process is old.
|
|
849
|
+
#
|
|
850
|
+
# THE WRAPPER FILE RATHER THAN THE MANIFEST, and that is a measurement too:
|
|
851
|
+
# the one genuinely orphaned desk on this machine carries NO manifest — the
|
|
852
|
+
# registry it was written to has moved — and gating on one defeated the
|
|
853
|
+
# detection for exactly the population this exists to serve. The wrapper
|
|
854
|
+
# file is also the better evidence: the manifest is written by the
|
|
855
|
+
# dispatcher BEFORE the launch, while this file is written by the wrapper
|
|
856
|
+
# process itself, so its presence proves a worker really ran here. It is the
|
|
857
|
+
# file's own rule one line over — *"the process that knows a pid is the one
|
|
858
|
+
# that writes it"*.
|
|
859
|
+
if [ ! -f "$wt/.plot-worker.wrapper.pid" ]; then
|
|
860
|
+
printf 'running\t%s\t' "$pid"
|
|
861
|
+
return
|
|
862
|
+
fi
|
|
863
|
+
if plot_worker_agent_alive "$pid"; then
|
|
864
|
+
printf 'running\t%s\t' "$pid"
|
|
865
|
+
return
|
|
866
|
+
elif [ "$?" -eq 1 ]; then
|
|
867
|
+
# The wrapper is alive and the agent is gone. The DESK decides what that
|
|
868
|
+
# means — `stalled` for work only this machine holds, `waiting` for a
|
|
869
|
+
# marker, `finished` for a desk that is clear — because the process has
|
|
870
|
+
# nothing left to say. No exit file exists: the wrapper has not exited.
|
|
871
|
+
printf '%s\t%s\t' "$(plot_worker_task_state "$wt" "$has_pr")" "$pid"
|
|
872
|
+
return
|
|
873
|
+
fi
|
|
874
|
+
# Unaskable: the process table could not be read for this pid. Report what
|
|
875
|
+
# `kill -0` established and nothing more.
|
|
695
876
|
printf 'running\t%s\t' "$pid"
|
|
696
877
|
return
|
|
697
878
|
fi
|
|
@@ -780,7 +961,9 @@ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
|
|
|
780
961
|
#
|
|
781
962
|
# worktree_here pid_recorded liveness exit blocked dirty unpushed
|
|
782
963
|
#
|
|
783
|
-
# `liveness` is `live`, `stale` or `dead
|
|
964
|
+
# `liveness` is `live`, `stale`, `orphaned` or `dead` — `orphaned` being a pid
|
|
965
|
+
# that answers with no agent process under it, which is a live wrapper whose
|
|
966
|
+
# agent has gone; `exit` is the code as read, empty for
|
|
784
967
|
# an unreadable record and the literal `-` for an absent one, because an empty
|
|
785
968
|
# field cannot say which of the two it is and the rule answers them alike only
|
|
786
969
|
# because it was told they differ. The PR fact is NOT here: it comes from the
|
|
@@ -825,7 +1008,23 @@ plot_worker_readings() { # $1=worktree → "here\tpid\tliveness\texit\tblocked\t
|
|
|
825
1008
|
# uncheckable pid honest rather than pessimistic.
|
|
826
1009
|
if [ -n "$started_at" ] && ! plot_pid_is_current "$pid" "$started_at"; then
|
|
827
1010
|
liveness=stale
|
|
1011
|
+
elif [ ! -f "$wt/.plot-worker.wrapper.pid" ]; then
|
|
1012
|
+
# NO WRAPPER FILE, NO AGENT QUESTION — the gate `plot_worker_state`
|
|
1013
|
+
# applies, repeated here because these two must not drift: a desk Plot
|
|
1014
|
+
# never launched a worker into has no agent Plot can expect.
|
|
1015
|
+
liveness=live
|
|
1016
|
+
elif plot_worker_agent_alive "$pid"; then
|
|
1017
|
+
liveness=live
|
|
1018
|
+
elif [ "$?" -eq 1 ]; then
|
|
1019
|
+
# THE FOURTH WORD, and it belongs in this field rather than in a private
|
|
1020
|
+
# branch inside `plot_worker_state`. `stale` set the precedent: it means
|
|
1021
|
+
# *the pid exists and is not our worker*, and this means *the pid exists
|
|
1022
|
+
# and our worker is no longer inside it*. Both are facts about what the
|
|
1023
|
+
# recorded pid names, which is what this field is.
|
|
1024
|
+
liveness=orphaned
|
|
828
1025
|
else
|
|
1026
|
+
# Unaskable — `kill -0` answered, the process table did not. Report what
|
|
1027
|
+
# was established.
|
|
829
1028
|
liveness=live
|
|
830
1029
|
fi
|
|
831
1030
|
fi
|