@plot-pm/board 0.16.3 → 0.18.0

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
@@ -219,13 +219,11 @@
219
219
  # empty: `jen job list` carries no history and
220
220
  # no timestamps, and inventing them would be a
221
221
  # collector reaching a verdict.
222
- # run-for-sha <branch> <sha> the run for ONE sha — else the branch's newest
223
- # run, with `sha` saying which it is — as a
224
- # single JSON object, or nothing when the branch
225
- # has no runs at all. Dispatched on the `CI` key
226
- # like `runs`, and EXITS 4 on jenkins: `jen job
227
- # list` names no commit, so there is nothing to
228
- # match a sha against. Output:
222
+ # run-for-sha <branch> <sha> the run for ONE sha, and nothing for any
223
+ # other sha — as a single JSON object, or
224
+ # nothing when the branch has no run for this
225
+ # sha. Dispatched on the `CI` key like `runs`.
226
+ # Output:
229
227
  # {"sha":"…","status":"queued|in_progress|
230
228
  # completed|waiting|requested",
231
229
  # "conclusion":"success|failure|…|null",
@@ -235,22 +233,12 @@
235
233
  # and sha-blind, and `gh run list --branch X`
236
234
  # returns runs for every sha that branch ever
237
235
  # had — the newest run is NOT necessarily for
238
- # the newest commit. A green answer read off the
239
- # wrong run reports success for code nobody will
240
- # merge, which is worse than no answer: it
241
- # invites a merge of the wrong thing. Measured
242
- # 2026-08-30: two merge waiters reported on
243
- # superseded runs and had to be stopped and
244
- # re-armed.
245
- # THE FALLBACK IS WHAT MAKES THAT VISIBLE. Were
246
- # it to report nothing when the asked-for sha
247
- # has no run, a run IN FLIGHT for a superseded
248
- # commit would look exactly like no run at all,
249
- # and a caller could not tell "CI has not
250
- # started" from "CI is answering about the
251
- # past". `sha` names the run's own commit, and
252
- # comparing it to the one asked about is the
253
- # CALLER's rule — this decides nothing.
236
+ # the newest commit. A run for any sha but the
237
+ # one asked about is not evidence about it, so
238
+ # reporting it would invite a merge of the
239
+ # wrong thing. Measured 2026-08-30: two merge
240
+ # waiters reported on superseded runs and had
241
+ # to be stopped and re-armed.
254
242
  # `status` AND `conclusion` ARE BOTH REPORTED,
255
243
  # never collapsed. A run that is `completed` has
256
244
  # a conclusion; one that is `waiting` or
@@ -474,7 +462,6 @@ die3() { echo "plot-host: $*" >&2; exit 3; }
474
462
  # a person at a terminal want three different answers — and a retry inside the
475
463
  # adapter would hide the very state this code exists to surface, turning a
476
464
  # reportable fact into an unexplained four-minute call.
477
- die5() { echo "plot-host: $*" >&2; exit 5; }
478
465
 
479
466
  # Exit 6 — the host refused because too many calls arrived AT ONCE. A secondary
480
467
  # limit, and a different ceiling from the one exit 5 reports.
@@ -497,7 +484,6 @@ die5() { echo "plot-host: $*" >&2; exit 5; }
497
484
  #
498
485
  # NOT A RETRY, for the reason exit 5 states: whether to wait is the caller's
499
486
  # decision, and this adapter reports rather than reacts.
500
- die6() { echo "plot-host: $*" >&2; exit 6; }
501
487
 
502
488
  # WHICH FAILURE, read off the wording — the same shape `bb_issue_exit_code`
503
489
  # uses, and for the same reason: the exit code cannot split these cases. `gh`
@@ -514,7 +500,7 @@ die6() { echo "plot-host: $*" >&2; exit 6; }
514
500
  # `throttled` for every match of one regex until 2026-09-02, so *"API rate
515
501
  # limit exceeded"* and *"You have exceeded a secondary rate limit"* came back
516
502
  # the same word and nothing downstream could tell them apart. `secondary` is
517
- # now its own answer: `die6` carries it, and the board names which limit was
503
+ # now its own answer: `pr_list_failed` exits 6 for it, and the board names which limit was
518
504
  # hit rather than printing one reset over both.
519
505
  #
520
506
  # THE SECONDARY TEST RUNS FIRST, AND THE ORDER IS THE CLASSIFICATION. GitHub's
@@ -651,7 +637,7 @@ pr_list_failed() { # $1=stderr text
651
637
  #
652
638
  # EVERY CALL SITE MUST WRITE `|| exit $?`, AND IT IS NOT OPTIONAL. This is
653
639
  # invoked as `_raw="$(pr_list_call …)"` — a COMMAND SUBSTITUTION, which is a
654
- # subshell — so the `exit` inside `die5`/`die3` leaves that subshell only. The
640
+ # subshell — so the `exit` inside `pr_list_failed`/`die3` leaves that subshell only. The
655
641
  # outer script would carry on with `_raw` empty and `jq` would emit nothing:
656
642
  # the silent empty list this whole helper exists to remove, rebuilt one layer
657
643
  # further in and harder to see. The same trap `bb_states_for` documents a few
@@ -3619,7 +3605,8 @@ case "$op" in
3619
3605
  *) die "pr-merged: unknown arg $1" ;;
3620
3606
  esac
3621
3607
  done
3622
- if [ "$be" = "github" ]; then
3608
+ # NO REMOTE, NO PR: no repository holds one, so nothing merged, whatever gh's auth says.
3609
+ if [ ${#repo_args[@]} -eq 0 ] && [ -z "$(git remote 2>/dev/null)" ]; then echo "not-merged"; elif [ "$be" = "github" ]; then
3623
3610
  # --state all, because a merged PR reports CLOSED and the default `open`
3624
3611
  # would hide every one of them. --limit 100 rather than 1: the newest PR
3625
3612
  # is not the merge, exactly as the state is not the merge.
@@ -4349,7 +4336,7 @@ case "$op" in
4349
4336
  ;;
4350
4337
 
4351
4338
  run-for-sha)
4352
- # The newest run for ONE sha — the BuildMonitor's only host question.
4339
+ # The run for ONE sha, or nothing — the BuildMonitor's only host question.
4353
4340
  #
4354
4341
  # WHY THIS IS NOT `runs`. `runs` is branch-scoped and reports no sha at all,
4355
4342
  # so a caller cannot tell which commit an answer is about. `gh run list
@@ -4376,9 +4363,9 @@ case "$op" in
4376
4363
  # deliberately NOT an error: a monitor polling a fresh push sees it on every
4377
4364
  # pass until CI wakes up.
4378
4365
  #
4379
- # Bitbucket reports nothing rather than something invented, exactly as
4380
- # `runs` does: `bb` has no run listing, and silence here reads as
4381
- # unavailable, never as "this sha has no build".
4366
+ # A Bitbucket remote exits 4 (`bb` has no run listing), as does a failing
4367
+ # `gh` or Jenkins that cannot be asked: exit 4 reads as unavailable, never
4368
+ # as "this sha has no build".
4382
4369
  branch="${1:?run-for-sha needs a branch}"; shift
4383
4370
  sha="${1:?run-for-sha needs a sha}"; shift
4384
4371
  # Enough runs to find the sha among its neighbours. A branch accumulates
@@ -4467,10 +4454,9 @@ case "$op" in
4467
4454
  echo "plot-host: run-for-sha — Jenkins did not answer for '$_jen_host'" >&2
4468
4455
  exit 4
4469
4456
  fi
4470
- # THE SAME FALLBACK RULE AS THE GITHUB ARM, and it is inherited rather
4471
- # than invented: the asked-for sha if a build carries it, else the
4472
- # newest build, and `sha` says WHICH. A caller that could not tell the
4473
- # two apart would be back to the branch-scoped guessing this op ends.
4457
+ # THE SAME MATCH RULE AS THE GITHUB ARM: only the asked-for sha, never
4458
+ # another build's. A build for any other commit is not evidence about
4459
+ # this one, so no match means no output.
4474
4460
  #
4475
4461
  # `result` is null while a build runs, which is Jenkins' own word for
4476
4462
  # *in flight* — mapped to the `status`/`conclusion` split the contract
@@ -4482,7 +4468,7 @@ case "$op" in
4482
4468
  conclusion: (if .building then null else (.result // null) end),
4483
4469
  url: (.url // ""),
4484
4470
  startedAt: (if .timestamp then (.timestamp / 1000 | todate) else "" end) } ]
4485
- | ((map(select(.sha == $sha)) | .[0]) // .[0])
4471
+ | (map(select(.sha == $sha)) | .[0])
4486
4472
  | select(. != null)' 2>/dev/null || true
4487
4473
  # THIS ARM ANSWERS AND THE OP IS OVER. Everything below the `esac` is
4488
4474
  # the GitHub path — the old jenkins arm reached it only because it
@@ -4512,30 +4498,36 @@ case "$op" in
4512
4498
  # newest-first, and a sha can carry several (a rerun, or several
4513
4499
  # workflows). The newest is the live answer; older ones for the same sha
4514
4500
  # are superseded by the same argument that superseded runs for older shas.
4515
- # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
4516
- # and `sha` in the output says WHICH, because a caller that could not tell
4517
- # the two apart would be back to the branch-scoped guessing this op exists
4518
- # to end.
4519
4501
  #
4520
- # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
4521
- # the asked-for sha and stopping makes the most important case invisible:
4522
- # a run IN FLIGHT for a commit the branch has already moved past reports
4523
- # identically to no run at all, so a caller cannot distinguish *CI has not
4524
- # started yet* from *CI is busy answering about the past*. The second is
4525
- # the state that had two merge waiters reporting on superseded runs on
4526
- # 2026-08-30, and it is exactly what a caller needs to see.
4502
+ # ONLY THE ASKED-FOR SHA, NEVER ANOTHER ONE'S RUN. A run for any other
4503
+ # commit is not evidence about this one — reporting it would read as a
4504
+ # live answer for a commit the branch has already moved past, which is
4505
+ # worse than no answer. No match means no output, read the same as a
4506
+ # branch with no runs at all.
4527
4507
  #
4528
- # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
4529
- # the sha that run is for; whether that sha being different from the one
4530
- # asked about means "superseded" is the caller's rule. This collects.
4531
- gh run list --branch "$branch" --limit "$limit" \
4532
- --json headSha,conclusion,status,startedAt,url 2>/dev/null \
4533
- | jq -c --arg sha "$sha" \
4534
- '(map(select(.headSha == $sha)) | .[0]) // .[0]
4535
- | select(. != null)
4536
- | {sha:.headSha, status:.status,
4537
- conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
4538
- url:.url, startedAt:.startedAt}' 2>/dev/null || true
4508
+ # A FAILING `gh` IS NOT AN EMPTY HISTORY. An expired token, a rate limit or
4509
+ # a network failure exits 4 here, the way the Jenkins arm does, so the
4510
+ # monitor reads *could not ask* and not *no run yet*.
4511
+ _gh_runs=$(gh run list --branch "$branch" --limit "$limit" \
4512
+ --json headSha,conclusion,status,startedAt,url,databaseId 2>/dev/null) \
4513
+ || { echo "plot-host: run-for-sha — gh run list failed for '$branch'" >&2; exit 4; }
4514
+ _gh_match=$(printf '%s' "$_gh_runs" | jq -c --arg sha "$sha" \
4515
+ '(map(select(.headSha == $sha)) | .[0]) | select(. != null)
4516
+ | {sha:.headSha, status:.status,
4517
+ conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
4518
+ url:.url, startedAt:.startedAt, databaseId:.databaseId}') || exit 4
4519
+ [ -n "$_gh_match" ] || exit 0
4520
+ # ONE MORE CALL, ONLY FOR A CONCLUDED FAILURE (#1295): `gh run view --json
4521
+ # jobs` is the only place a 0-step job (no runner ever picked up the run)
4522
+ # shows up, apart from a real failure. A run still going or that
4523
+ # succeeded costs no extra call.
4524
+ _gh_db_id=$(printf '%s' "$_gh_match" | jq -r 'if (.conclusion == "failure" or .conclusion == "cancelled") then .databaseId else "" end')
4525
+ _gh_match=$(printf '%s' "$_gh_match" | jq -c 'del(.databaseId)') || exit 4
4526
+ [ -z "$_gh_db_id" ] && { printf '%s' "$_gh_match"; exit 0; }
4527
+ # Slurped via stdin, never `--argjson`: that flag caps at 128 KB on Linux.
4528
+ _gh_jobs=$(gh run view "$_gh_db_id" --json jobs 2>/dev/null) || { echo "plot-host: run-for-sha — gh run view failed" >&2; exit 4; }
4529
+ jq -n -c --slurpfile match <(printf '%s' "$_gh_match") --slurpfile run <(printf '%s' "$_gh_jobs") \
4530
+ '$match[0] + {jobs: [$run[0].jobs[] | {conclusion, steps: (.steps | length)}]}' || exit 4
4539
4531
  ;;
4540
4532
 
4541
4533
  issue-list)
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: the ONE answer to "is this monitor's subject still there?"
3
3
  #
4
- # SOURCED, NOT RUN, by `plot-agent-monitor.sh` and `plot-build-monitor.sh`.
5
- # Both need the same computation and neither renders it the same way, which is
6
- # the same shape as `plot-worker-state.sh` and `plot-pr-merged.sh` — and the
7
- # same reason. `plot-worker-state.sh` carried five of its six states in
8
- # duplicate until 2026-08-18, and the copies had already drifted on the sixth.
9
- # Two monitors deciding independently when to stop would drift the same way, and
10
- # the failure would be silent: one monitor left running forever while its twin
11
- # exits is exactly the leak this file exists to close, half-fixed.
4
+ # SOURCED, NOT RUN, by `plot-agent-monitor.sh`. The same shape as
5
+ # `plot-worker-state.sh` and `plot-pr-merged.sh` — and the same reason.
6
+ # `plot-worker-state.sh` carried five of its six states in duplicate until
7
+ # 2026-08-18, and the copies had already drifted on the sixth. A monitor
8
+ # deciding independently when to stop, with its computation copied rather than
9
+ # shared, would drift the same way, and the failure would be silent: a monitor
10
+ # left running forever is exactly the leak this file exists to close.
12
11
  #
13
12
  # ═══════════════════════════════════════════════════════════════════════════
14
13
  # WHY A MONITOR NEEDS THIS AT ALL
@@ -499,6 +499,15 @@ plot_worker_blocked_file() { # $1=worktree → prints the marker's basename
499
499
  # exact population this state must not name. Excluding them is not widening the
500
500
  # rule; it is the `.tmp1` case again, for files Plot itself dropped there.
501
501
  #
502
+ # NOR IS AN UNTRACKED ROOT `PLOT-CORRECTION.md`. `plot-worker-loop.sh`'s
503
+ # `write_correction` writes it untracked into the desk for the agent to read,
504
+ # and `reset_desk` removes it when the desk takes the next slice. A desk whose
505
+ # only other content is that file holds no unlanded work, so the loop waits for
506
+ # the checks rather than ending `holding-work`. The match is the whole
507
+ # porcelain line `?? PLOT-CORRECTION.md`, the line `plot-desk-dirt.sh`'s
508
+ # `desk_dirt` drops: a `docs/PLOT-CORRECTION.md`, or a staged, modified or
509
+ # deleted copy, is content and counts.
510
+ #
502
511
  # THE EXCLUSION STAYS NARROW OTHERWISE, by suffix and by Plot's own filenames.
503
512
  # An uncommitted source file is precisely the case this detection exists for, so
504
513
  # anything broader — "untracked files do not count", "only tracked changes
@@ -565,7 +574,7 @@ plot_worker_dirty_filter() { # $1=`git status --porcelain` output $2=worktree (o
565
574
  # `--porcelain` is the STABLE format; `git status` prose is localised and
566
575
  # reflows. Cut at column 4: the first three bytes are the XY status pair and a
567
576
  # space, and a filename can contain spaces of its own.
568
- printf '%s' "$status" \
577
+ printf '%s' "$status" | grep -vxF '?? PLOT-CORRECTION.md' \
569
578
  | cut -c4- \
570
579
  | grep -vE "(^|/)$PLOT_WORKER_RECORD" \
571
580
  | grep -vE "$PLOT_EDITOR_LEFTOVER" \
@@ -829,29 +838,6 @@ plot_worker_limited_reset() { # $1=worktree → epoch seconds | ""
829
838
  printf '%s' "$reset"
830
839
  }
831
840
 
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
841
  # Are there commits on this branch yet? → 0 yes | 1 no | 2 unanswerable
856
842
  #
857
843
  # THE THIRD CONDITION ON `idle`, and the one that separates a stall from an
@@ -887,145 +873,6 @@ plot_worker_has_commits() { # $1=worktree → 0 yes | 1 no | 2 unanswerable
887
873
  return 1
888
874
  }
889
875
 
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
-
1029
876
  # The total CPU time, in centiseconds, of a pid and every process descended from
1030
877
  # it. Prints the number; prints `0` and returns non-zero when the pid names no
1031
878
  # live process at all.