@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/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" "github" | tr '[:upper:]' '[:lower:]')"
1766
+ v="$(bash "$here/plot-config.sh" get "Git host" "" | tr '[:upper:]' '[:lower:]')"
1726
1767
  case "$v" in
1727
- bb) echo "bitbucket" ;;
1728
- "") echo "github" ;;
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
- v="$(backend_declared)" || return 1
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
- # JENKINS CANNOT ANSWER THIS THROUGH `jen`, AND THAT IS A MEASUREMENT
2995
- # RATHER THAN A GAP LEFT OPEN. `jenkins_build_map` the only Jenkins
2996
- # reader this script has answers `{color, checks, job}` per BRANCH and
2997
- # carries no commit at all, so there is nothing here to match a sha
2998
- # against.
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 ANSWER EXISTS AND THIS TRANSPORT DOES NOT REACH IT. Measured
3001
- # 2026-09-08 against a live instance, a build names its commit at
3002
- # `actions[].BuildData.lastBuiltRevision.SHA1`, paired with its branch
3003
- # over the REST API, which `the-ci-connector-is-jenkins` reads. Until
3004
- # that lands, `unaskable` is the true word: this connector cannot be
3005
- # asked, which is not the same as the branch having no run for the sha.
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
- # FALLING BACK TO THE BRANCH'S CURRENT STATE WOULD BE THE ONE ANSWER
3008
- # THAT COSTS A MERGE. This op exists because a run for a superseded
3009
- # commit reads identically to a run for the current one, and reporting
3010
- # a branch-scoped state with no sha in it is exactly the guessing it was
3011
- # written to end — two merge waiters were stopped for it on 2026-08-30.
3012
- echo "plot-host: run-for-sha jenkins has no sha-scoped answer through \`jen\`" >&2
3013
- echo " \`jen job list\` reports one state per branch and names no commit." >&2
3014
- echo " A build's sha is in Jenkins and reached over its REST API, which" >&2
3015
- echo " the Jenkins build connector reads. Until then this cannot be asked." >&2
3016
- exit 4
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" ;;
@@ -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
- # The process is running AND current (or uncheckable, with no startedAt).
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`; `exit` is the code as read, empty for
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