Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 364 additions & 0 deletions .github/workflows/half-state-patrol.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
name: Half-State Patrol

# The standing caller for `scripts/pm/check-half-states.mjs`.
#
# ## ⚠️ THIS IS THE ADOPTED COPY (objectui#5791) — upstream is objectstack
#
# Ported from `objectstack-ai/objectstack` (PR #11294), NOT authored here. The
# prose below is upstream's and its issue numbers (#9844, #4449, #9575, #4690,
# #7412, #11217 …) are OBJECTSTACK numbers — do not read them as objectui cards.
#
# Every divergence from upstream is listed here, once, so a future re-sync knows
# exactly what it must not clobber:
#
# 1. `PM_SWEEP_CLOSED_WINDOW_PAGES: 0` on the sweep step — H22's closed-card
# reader is OFF in this install. Measured 2026-08-24: 815 closed cards here
# carry `pm:dispatched`, and ~347 of the 400 issues in upstream's window
# carry some `pm:*` residue label (~87%, against the 26% upstream measured).
# Stripping `pm:*` on close was never this lane's practice, so H22 here
# reports the CONVENTION, not a defect — ~347 rows that would consume the
# whole body budget and trim every other predicate out of the anchor. The
# sweeper says "UNREAD, not clean" in its summary rather than reporting 0.
# ⛔ Re-enabling is a BACKFILL decision (strip the residue first), never a
# quiet default flip. The script's default is still upstream's 4.
# 2. `scripts/invoked-as.mjs` is in the `paths:` filter below — the sweeper
# imports it, and it was ported alongside.
# 3. `scripts/pm/check-half-states.mjs` carries `DEFAULT_SWEEP_REPO =
# 'objectstack-ai/objectui'` so a bare terminal run here sweeps THIS board.
#
# Nothing else diverges; the predicates are untouched.
#
# ## Why a workflow, and not "a seat should run it"
#
# The sweeper carries thirteen predicates over the dispatch protocol's
# label/assignee/PR invariants, and for most of its life its documented consumer
# was "a PM seat's patrol round" — which is to say, nobody's calendar. A shift
# covering two lanes declared a queue empty from memory while eight malformed
# claims (H2) and an unenumerated backlog sat on the board. Not one predicate had
# fired. A healing mechanism with no scheduled caller heals only in the
# counterfactual, and an alarm added to a script nobody runs is still silence.
#
# "Some seat should run it" also kept not happening for a MEASURED reason, not a
# discipline one: the live sweep cannot run inside a PM session container at all
# (#7412 class 1 — api.github.com refuses that egress in both directions, with and
# without a token). The fix therefore had to move the caller somewhere the
# transport prerequisite is actually met. A GitHub Actions runner with the
# workflow's own `GITHUB_TOKEN` is that place — #7412 class 2, the triage Routine
# container, is the same shape and measured reachable with 15,000 core quota.
#
# ## What lands where
#
# One pinned ANCHOR ISSUE, rewritten in place every run (`ANCHOR_ISSUE` below).
# Never a comment per run: the board is one board, a per-run comment stream would
# be a second tracker that nobody prunes, and GitHub's edit history is already the
# archive this needs. The body is owned end-to-end by the generator, so no run can
# leave half of it stale.
#
# The `Swept` timestamp in that body is the patrol's heartbeat and is deliberately
# refreshed even when the findings are unchanged: a timestamp that stops advancing
# is how a reader learns the standing caller died. That is the whole defect class
# this workflow exists to close, so the run must not "optimize away" the no-op
# edit that proves it is alive.
#
# ## Report-only, and the one thing that is NOT report-only
#
# Findings never fail anything. A completed sweep exits 0 whether it found 0 or 40
# half-states, this job never writes a label, never closes a card, never fixes a
# state, and no H-predicate is a blocking gate — the script's own header argues
# that at length (a half-state is a fact about a live shared board, not about
# whichever PR happens to run CI next).
#
# The job DOES fail when the sweep could not run, or when its report could not be
# delivered. That is not a gate on the board; it is the patrol reporting its own
# death. A workflow that quietly does nothing because a credential lapsed is the
# exact shape this repo keeps having to fix (#4449, #9575), and it is doubly
# unacceptable here: silent non-delivery would leave a stale anchor body that
# reads exactly like a clean board — the #4690 failure ("could not read the input"
# must never look like "input is clean") with a timestamp on it. Failing costs
# nobody a PR: this workflow gates no branch and blocks no queue.
#
# ## Adopting this in a sibling repo (#11217)
#
# This file is REPO-AGNOSTIC and is meant to be copied verbatim. It was not:
# installed in objectstack alone, it left 37 of the fleet's 59 open `pm:blocked`
# cards outside any patrol, and a hand-run of H19's predicate over objectui's
# blocked inventory found 7 blocks whose blocker had already closed — 58% of
# that repo's machine-readable blocks were false, one of them for a week. The
# same predicate had been catching objectstack's four every hour. The difference
# was never discipline; it was that one repo had a caller.
#
# To adopt, in the sibling repo:
#
# 1. copy `scripts/pm/check-half-states.mjs` and this file, unchanged;
# 2. open one `tracking`-labeled anchor issue there and set the repository
# VARIABLE `HALF_STATE_ANCHOR_ISSUE` to its number
# (Settings → Secrets and variables → Actions → Variables).
#
# That is the whole install. The swept repo needs no configuration at all: it is
# `github.repository`, so the copy reads the board it lives in — a hardcoded
# default was how a copied file could have swept THIS repo and written the
# findings into a sibling's anchor, a fully green report about the wrong board.
#
# ⛔ Each install uses its OWN `secrets.GITHUB_TOKEN` and reads its own repo. No
# cross-repo credential, no matrix over repos, no PAT: that route was refused at
# grading (it buys no coverage a per-repo install lacks and raises the
# credential floor for every repo at once). The accepted consequence is that a
# cross-repo `Blocked-by:` target stays UNJUDGED in each install — H19 says so
# in its own row rather than reading it as a healthy block.

on:
schedule:
# Four times a day, six hours apart, at :37 past the hour.
#
# The minute is offset ON PURPOSE. The triage Routine that heals these same
# states fires hourly near the top of the hour, and a patrol landing at the
# same minute would keep reading the board mid-heal — reporting half-states
# the healer is in the middle of pairing, i.e. manufacturing findings that
# clear themselves. :37 puts this sweep in the quiet part of the healer's
# cycle in both directions. Four runs/day rather than hourly: H13's own
# threshold is 2h and the incident it comes from sat ~26h, so six-hourly
# detection is two orders of magnitude better than the status quo (never)
# while staying cheap on the core quota this sweep shares with the loop's
# hot path.
- cron: '37 1,7,13,19 * * *'
workflow_dispatch: {}
# Changes to the patrol itself get exercised before they merge — the same
# posture as engine-split-metric.yml. On a pull_request run the sweep still
# executes (that is the point: the transport, the flags and the rendering are
# proven on a real runner), but the anchor write is skipped and the rendered
# body goes to the run's step summary instead. A PR must never rewrite the
# board's pinned view.
pull_request:
paths:
- 'scripts/pm/check-half-states.mjs'
# objectui#5791: the sweeper imports this helper, so a change to it can
# break the patrol without touching either file above.
- 'scripts/invoked-as.mjs'
- '.github/workflows/half-state-patrol.yml'

# Least privilege: this job reads the repo and writes exactly one issue BODY.
# `issues: write` is the narrowest scope GitHub offers for that edit; the job
# never uses it for labels, comments, assignees or state, and the sweeper it
# calls is read-only against the API by construction.
permissions:
contents: read
issues: write

# One patrol at a time. A scheduled run overlapping a manual dispatch would have
# two runs racing to rewrite the same body, and the loser's findings would vanish
# with no trace but an edit-history entry.
concurrency:
group: half-state-patrol
cancel-in-progress: false

env:
# The pinned anchor issue whose body this workflow owns — the ONE per-repo
# input this file takes (#11217).
#
# Resolution: the repository variable `HALF_STATE_ANCHOR_ISSUE` if set, else
# this repo's own pinned number, else EMPTY — and empty makes the job refuse
# to write rather than guess (see the "Resolve the anchor" step). The literal
# is guarded by the repository name on purpose: an unguarded fallback is what
# would let a verbatim copy in objectui rewrite ITS #9857 — some unrelated
# card — with this board's findings, silently and four times a day. A number
# is only ever meaningful in the repo it was minted in.
#
# TO ROTATE (here): open a new `tracking`-labeled issue, put its number below,
# and note the handover in the OLD issue's body before closing it (its edit
# history is the archive and does not travel).
# TO ADOPT (a sibling repo): change NOTHING here — set the repository variable.
#
# The anchor deliberately carries `tracking` and NO `domain:*` label: `tracking`
# is in the sweeper's own H13_EXEMPT_LABELS, so the anchor can never appear as a
# finding in the sweep it hosts.
#
# ⚠️ Folded scalar, and every continuation line sits at the SAME indent on
# purpose: a more-indented line in a `>-` block keeps its newline literally
# (measured on this very value), which would hand the expression parser a
# multi-line string instead of one expression.
ANCHOR_ISSUE: >-
${{ vars.HALF_STATE_ANCHOR_ISSUE
|| (github.repository == 'objectstack-ai/objectstack' && '9857')
|| '' }}

jobs:
patrol:
name: Live half-state sweep
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'

# No `pnpm install`: the sweeper imports nothing but `node:process` and
# global `fetch`. Installing the workspace here would buy nothing and would
# give a scheduled patrol a lockfile it could fail on.
- name: Run the live sweep
id: sweep
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# WHICH board this run reads: the repo this workflow is installed in,
# always. The sweeper would resolve the same answer on its own from
# the runner's `GITHUB_REPOSITORY` (`resolveSweepRepo`), and it is
# passed explicitly anyway so the wiring is visible to a reader of the
# workflow — the two agree by construction and a copy of this file
# cannot end up sweeping the repo it was copied FROM.
PM_SWEEP_REPO: ${{ github.repository }}
# objectui#5791 — H22's closed-card reader is OFF here. See divergence
# (1) in this file's header for the measurement that decided it, and
# `resolveClosedWindowPages` in the sweeper for what 0 means. The
# rendered body reports the surface as UNREAD, never as clean.
PM_SWEEP_CLOSED_WINDOW_PAGES: '0'
PROVENANCE: >-
run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
· commit `${{ github.sha }}` · trigger `${{ github.event_name }}`
run: |
set +e
node scripts/pm/check-half-states.mjs \
--format=markdown \
--provenance="$PROVENANCE" \
> "$RUNNER_TEMP/report.md" 2> "$RUNNER_TEMP/report.err"
code=$?
set -e
# Captured with NO pipe in between. `cmd | tail` would report the
# PIPE's status — `tail` essentially never fails, so a green and a red
# sweep both read as 0, and the script's own header calls this trap out
# by name (its exit codes are 0 / 2 / 3 and the split is the point).
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
echo "check-half-states exited $code"
cat "$RUNNER_TEMP/report.err" >&2 || true

- name: Resolve the anchor issue
# An install with no anchor configured has nowhere to land its report,
# and the ONLY safe behaviour is to say so loudly (#11217). The two
# alternatives are both the failure this file exists to prevent:
# guessing a number would rewrite an unrelated card in this repo, and
# skipping the write quietly would leave a patrol that runs, finds, and
# tells nobody — indistinguishable from a clean board.
#
# Placed AFTER the sweep so the run summary still carries the rendered
# findings (the same "land the truth, then raise the alarm" order the
# final step keeps), and skipped on a pull_request run, which never
# writes an anchor at all.
if: github.event_name != 'pull_request'
run: |
if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ]; then
echo "::error::No anchor issue configured for ${{ github.repository }}. The sweep RAN (see the run summary) but has nowhere to land. Open a \`tracking\`-labeled anchor issue in this repo and set the repository variable HALF_STATE_ANCHOR_ISSUE to its number (Settings -> Secrets and variables -> Actions -> Variables)."
exit 1
fi
case "$ANCHOR_ISSUE" in
*[!0-9]*|'') echo "::error::HALF_STATE_ANCHOR_ISSUE is '$ANCHOR_ISSUE', which is not an issue number."; exit 1 ;;
esac
echo "anchor: #$ANCHOR_ISSUE in ${{ github.repository }}"

- name: Update the pinned anchor issue
# A pull_request run proves the sweep; it must not touch the board.
if: github.event_name != 'pull_request'
uses: actions/github-script@v9
env:
SWEEP_EXIT: ${{ steps.sweep.outputs.exit_code }}
with:
# Delivery is retried, never assumed (#9575): this single PATCH is the
# entire product of the run, and a transient answer from the issues
# endpoint would otherwise discard a completed sweep.
retries: 3
script: |
const fs = require('fs');
const path = require('path');
const exitCode = Number(process.env.SWEEP_EXIT);
const anchor = Number(process.env.ANCHOR_ISSUE);
const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const read = (name) => {
try { return fs.readFileSync(path.join(process.env.RUNNER_TEMP, name), 'utf8'); }
catch { return ''; }
};

// The composition split, deliberately: a COMPLETED sweep renders its
// own body (in the script, where --self-test pins every property of
// it). Only the did-not-run body is composed here, because saying
// "my callee failed" is the caller's job and the script's classified
// output is already the authored explanation — this wraps it, it
// does not re-word it.
let body;
if (exitCode === 0) {
body = read('report.md');
if (!body.trim()) {
throw new Error('the sweep exited 0 but produced an empty report — refusing to blank the anchor');
}
} else {
const classified = (read('report.err') || read('report.md') || '(no output captured)').trim();
const kind = exitCode === 3
? 'PREREQUISITE NOT MET — the runner could not reach the board'
: 'SWEEP FAILED — an unclassified failure';
body = [
'os-half-state-sweep — machine-findable marker for this generated view.',
'',
`# ⛔ THE SWEEP DID NOT RUN (exit ${exitCode})`,
'',
`_Attempted ${new Date().toISOString()} · [run log](${runUrl}) · ${kind}._`,
'',
'Nothing below is a finding. **No issue was judged**, so this body says nothing about whether',
'the board carries half-states — it is not a clean board and it is not a dirty one, it is no',
'reading at all. A sweep that could not run must never read as a clean board.',
'',
'The standing patrol is DOWN until this is fixed; the previous run\'s findings are in this',
'issue\'s edit history. The sweeper\'s own classified output:',
'',
'```',
classified,
'```',
].join('\n');
}

await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: anchor,
body,
});
core.info(`anchor #${anchor} updated (${body.length} chars, sweep exit ${exitCode})`);

- name: Publish the rendered body to the run summary
# Always: on a PR this IS the delivery, and on a scheduled run it makes
# the run log self-contained when someone opens it after an alert.
if: always()
run: |
{
echo "### Half-state patrol — sweep exit ${{ steps.sweep.outputs.exit_code }}"
echo
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "_Anchor write skipped: a pull_request run proves the sweep without touching the board._"
echo
fi
echo '<details><summary>Rendered anchor body</summary>'
echo
cat "$RUNNER_TEMP/report.md" 2>/dev/null || echo '(no report produced)'
echo
echo '</details>'
echo
echo '<details><summary>stderr</summary>'
echo
echo '```'
cat "$RUNNER_TEMP/report.err" 2>/dev/null || true
echo '```'
echo
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"

- name: Fail the run if the sweep could not run
# LAST, on purpose: the anchor is updated with the did-not-run report
# BEFORE the job goes red. Land the truth, then raise the alarm — a run
# that failed early would leave the previous body in place with its old
# timestamp, which is precisely the stale-reads-as-clean shape above.
#
# Findings are NOT a failure condition and never appear here: exit 0 with
# 40 half-states is a successful patrol.
if: steps.sweep.outputs.exit_code != '0'
run: |
echo "::error::check-half-states exited ${{ steps.sweep.outputs.exit_code }} — the standing patrol did not read the board. See the anchor issue and this run's stderr."
exit 1
Loading
Loading