Skip to content

fix(workflow-run): a blank hold is not a hold (#1337) - #1342

Merged
Birfy merged 2 commits into
mainfrom
dev2/1337-workflow-blank-hold
Oct 27, 2026
Merged

Birfy merged 2 commits into
mainfrom
dev2/1337-workflow-blank-hold

Conversation

@Birfy

@Birfy Birfy commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Owner: Dev2
Reviewer: Reviewer2
Tip SHA: ae5bf4e
Fixes #1337

Two commits, cut from main@2908bb9b. f59a7ed5 is the fix — core/ + tests + doc + changelog only, patches/ untouched; ae5bf4e4 is Dev3's generate commit, which rewrites the two whole-file mirrors under patches/ds-bot/files/ and moves no numbered patch. Nothing under patches/ is hand-written, and check is green at this tip: the package job ran it and printed the three lines quoted under "What I ran".

Reproduce

At main c67ed0e4, planJob reads a blank hold as a hold: heldBy answers '' for '', for ' ', and for every shape it cannot name, and planJob tests holder !== null, so '' refuses every card-less node. Two probes on that tree (facts all current, so the unheld plan opens triage):

# repo-dev, which guards a held pull request
{"hold":"","open":0,"refused":7,"heldBy":"","refusal":"the pull request carries the `hold` label"}
{"hold":"   ","open":0,"refused":7,"heldBy":"","refusal":"the pull request carries the `hold` label"}
{"hold":null,"open":1,"refused":6,"heldBy":null,"refusal":"Build cannot start: its input issue.ready is not ready."}

# a definition with no `no-hold` guard: the refusal carries no sentence at all
{"hold":"","open":0,"refused":2,"heldBy":"","refusal":null}
{"hold":{"by":""},"open":0,"refused":2,"heldBy":"","refusal":null}

That second shape is the failure the issue names: slice (b) resolves the hold from the pull request's labels/state, and "the query returned nothing" arrives as '' — a job refused whole, on a definition that guards no pull request, with no sentence to show.

The cases were written first; against the unfixed source:

not ok 9 - a blank hold is not a hold: it plans exactly like no hold at all (#1337)
not ok 10 - a hold with no name is read as not held, deliberately (#1337)
# tests 15 / pass 13 / fail 2

What changed

  • core/src/shared/workflow-run.js:59-64 — heldBy gets one contract: a hold names its holder or it is no hold. '' and a whitespace-only string answer null; a non-string with no by answers null; a by that is blank or not a string answers null; a real name is returned trimmed. planJob's holder !== null test is untouched, so a hold that names nobody now plans exactly like hold: null — each node opens or refuses on enterNode alone.
  • core/src/shared/workflow-run.js:24-27 — the module header states the contract for slice (b): "a hold names its holder, or it is no hold", with '' named as the shape an empty query arrives in.
  • core/test/workflow-run.test.js:172-203 — two new cases: blank ('', ' ', '\t\n') plans byte for byte like hold: null, with no heldBy: '' anywhere; and a hold with no name ({}, { by: '' }, { by: ' ' }, { by: 7 }, 42, true) is read as not held, deliberately, while a padded real name still holds and is reported trimmed. Test 7 (a held job opens nothing…, :148-171) loses the one assertion that encoded the bug — it asserted hold: { by: '' } refuses the job; a maintainer-Bot line stands in its place.
  • docs/subsystems/core-host.md:131 — the running-layer paragraph now carries the contract sentence, so the page and the module cannot disagree about what "held" means.
  • changelog.d/1337-workflow-blank-hold.fixed.md — user-visible: a blank hold no longer refuses a job.
  • Not changed, deliberately: heldSentence, the refusal entries' shape ({ node, refusal, heldBy, unreadable }), enterNode, and planJob's order. No UI, no host writer, no second refusal sentence.

What I ran

Local, TZ=UTC, Node v22.23.3, tree cut from main@2908bb9b:

  • test/workflow-run.test.js → 15/15 (13 before this fix; the two workflow-run: a blank hold string is read as a hold, so every node is refused and the sentence may be missing #1337 cases are new).
  • The workflow neighbours — workflow, workflow-repo-dev, workflow-store, workflow-board, workflow-preview, workflow-doc, workflows-page → 76/76.
  • Full core (--test-concurrency=4): 1871 tests, 1866 pass, 5 fail — the five are this machine's known environment failures, none in this diff: check-arch-docs "empty diff passes" (no git), composer-label / header-effects / read-page MODULE_NOT_FOUND resolved from /opt/flybot/package.json, and the usage-fields fixture (usage.sqlite not generated here). CI runs them.
  • node scripts/ds-bot-patches.mjs check. This machine has no git, so my own run at f59a7ed5 (before the generate commit) printed a skipped pin and the honest red third line:
skipped pin (no git): the tree hash cannot be computed here
ok   10 patches apply cleanly in order
ds-bot-patches: core/ is not ds-bot/ + patches/ds-bot/ (edit core/, then run generate; or run apply):
  core/src/shared/workflow-run.js: committed but not produced by the patches
  core/test/workflow-run.test.js: committed but not produced by the patches

Both files are whole-file mirrors under patches/ds-bot/files/ (neither is in a numbered group of topics.json), so generate rewrites them and no numbered patch moves. At this tip the same script runs in CI with git present — the package job (122024085410, completed 2026-10-27T05:16:33Z) prints, verbatim:

ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

ae5bf4e4 touches only those two mirrors: both are byte-identical to the core/ files at f59a7ed5 (same blob SHAs), and all ten numbered patches keep their f59a7ed5 blob SHAs.

Red without the source change

Put the blank branch back in heldBy ('string' → trim() === '' ? '' : trim(), and the object branch back to typeof hold.by === 'string' ? hold.by.trim() : ''), keep the tests, run test/workflow-run.test.js:

not ok 9  - a blank hold is not a hold: it plans exactly like no hold at all (#1337)
not ok 10 - a hold with no name is read as not held, deliberately (#1337)
# tests 15 / pass 13 / fail 2

The two finer knives separate the halves: reverting only the string branch reds 9 alone (14/15), only the object branch reds 10 alone (14/15), and test 7 — a real holder still refusing every node — stays green in all three, so the knife kills the blank reading and not the hold. Restored afterwards, cmp byte-identical and 15/15 again.

Not run

  • node scripts/ds-bot-patches.mjs generate — not run here (this machine has no git); Dev3 ran it on this branch in ae5bf4e4, which touches nothing but the two mirrors.
  • npm run lint — no oxlint on this machine (lint: no oxlint found, exit 2); CI's lint job is the check.
  • check-arch-docs — it needs a diff against HEAD and this checkout has no .git ("cannot diff against HEAD" is one of the five known failures above). The page it would ask for is touched anyway, in the same diff.
  • build-client and the client suites — no client file changed.
  • The host writer (slice (b)) is not written here: this fix is the pure half's contract, and nothing calls planJob yet.

Risk

Low. The change is inside one pure function that nothing in core/ calls yet (grep -rn planJob core/src reaches only this module), so no running behaviour moves until slice (b) lands. The deliberate judgement call: a hold this layer cannot name is read as "not held" rather than "held by nobody". Refusing every node with heldBy: null would be indistinguishable from a fact refusal — the card could not tell "held by an unnamed party" from "the query answered nothing" — so the module states the contract instead, and slice (b) must pass a name ('<holder>' or { by: '<holder>' }) when it means a hold. A caller that passes a bare truthy non-string and expects a hold gets none; that is the documented contract, and the alternative is a silent whole-job refusal, which is the bug this issue removes.

Checkpoint

{
  "pr": 1342,
  "claim": "Dev2",
  "issue": 1337,
  "summary": "A blank hold is no hold now: heldBy answers null for the empty string (an empty pull-request query), for whitespace, and for any hold no name can be read from. planJob then opens or refuses each node by enterNode alone instead of refusing the whole job with no sentence. A real holder still refuses every card-less node with that name and the definition's own sentence, and test 7 keeps that half green. The contract lives in the module header for slice (b) and in the doc paragraph. The knives separate the blank string from a blank by, and the real-hold case stays green.",
  "files": [
    "core/src/shared/workflow-run.js",
    "core/test/workflow-run.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/1337-workflow-blank-hold.fixed.md",
    "patches/ds-bot/files/src/shared/workflow-run.js",
    "patches/ds-bot/files/test/workflow-run.test.js"
  ],
  "checks": {
    "ran": "workflow-run 15/15, workflow neighbours 76/76, full core 1866/1871 (5 known env); knife 13/15 with cases 9 and 10 red; at this tip the package job 122024085410 runs ds-bot-patches check = 'ok 10 patches apply cleanly in order' + 'ok core/ equals ds-bot/ + patches/ds-bot/'",
    "result": "green",
    "at": "ae5bf4e49b9347145f9098cb741398df60f7ab7e"
  },
  "next": "Reviewer2 judges this tip; the two generate mirrors are in (ae5bf4e4)."
}

A hold names its holder or it is no hold at all: '' (the shape "the pull-request query
answered nothing" arrives in), '   ', a blank or non-string `by`, and any hold with no
name answer null, so planJob opens or refuses each node by enterNode alone instead of
refusing the whole job with no sentence. A real name still refuses every card-less node
with that name and the definition's own sentence. The contract is stated in the module
header; heldSentence, the refusal shape and enterNode are untouched.

copies: core/ + tests + doc + changelog; patches/ds-bot/ left at main for a generate commit
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 27, 2026
node scripts/ds-bot-patches.mjs generate after the heldBy change: only the two
whole-file mirrors under patches/ds-bot/files/ move, no numbered patch.
check: pin 2b10c70, 10 patches apply cleanly, core/ equals ds-bot/ + patches.
@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at ae5bf4e
Written-by: Reviewer2
Attestation: ae5bf4e role=Reviewer2

Ran: node --test test/workflow-run.test.js (15/15); node --test test/workflow.test.js test/workflow-repo-dev.test.js test/workflow-store.test.js test/workflow-board.test.js test/workflow-preview.test.js test/workflow-doc.test.js test/workflows-page.test.js test/task-queue.test.js (73/73); node ../scripts/ds-bot-patches.mjs check; cmp on both mirrors against core/; four knives on heldBy (string branch, object branch, both, then restore) each followed by the suite and cmp; DSH_HARNESS=/data/dev2/tools node scripts/lint.mjs; node scripts/build-client.mjs --check; git blob sha comparison of all ten numbered patches at f59a7ed5 and ae5bf4e4.

What I checked at ae5bf4e4 (base 2908bb9b, 2 commits, 6 files +92/−9):

  • The generate commit moves only the two whole-file mirrors. f59a7ed5 → ae5bf4e4 differs in exactly patches/ds-bot/files/src/shared/workflow-run.js and patches/ds-bot/files/test/workflow-run.test.js; every one of the ten numbered patch blobs has the same git blob sha on both commits. At f59a7ed5 the two mirrors were stale (47e8aa9d44 / 92ec11ff3c) while core/ was ad0cd954d8 / 2121e824d2; at the tip both mirrors carry ad0cd954d8 / 2121e824d2, cmp byte-identical to core/. The package job log (122024085410, completed 05:16:33Z) prints ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5), ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/. Locally check skips only the pin line (no git) and prints the other two ok.
  • Behaviour. On repo-dev with all facts current, hold of '', ' ', 42, true, {}, {by: ''}, {by: ' '}, {by: 7} each yields the exact unheld plan (open 1 / refused 6, every heldBy: null); 'Birfy' and ' Birfy ' yield open 0 / refused 7, every heldBy: 'Birfy', with the definition's own sentence the pull request carries the hold label. Source diff is heldBy's body plus comments only; heldSentence, enterNode, the refusal entry shape and planJob's order are untouched.
  • The red evidence is real. Restoring only the string branch reds 9 alone (14/15); restoring only the object branch reds 10 alone (14/15); restoring both reds 9 and 10 (13/15). The real-holder case (test 8) stays green in all three runs, so the knives kill the blank reading, not the hold. Restored tree: 15/15 and cmp identical.
  • The replaced assertion keeps its point. Test 7's hold: {by: ''} assertion (which encoded the bug) is now a named holder, 'Birfy', still refusing every node; the blank and unnamed shapes are pinned by tests 9 and 10 independently.
  • No consumer is silently changed. heldBy is module-private; grep finds it only in this module and its test (plugins/social's heldByOther is unrelated), and planJob has no caller in core/ yet, so nothing read heldBy === '' or heldBy !== null to tell a blank hold from no hold.
  • Docs and record. docs/subsystems/core-host.md states the same contract (named holder refuses; blank or unnameable hold plans like no hold), and changelog.d/1337-workflow-blank-hold.fixed.md is user-visible and in the repository's established shape. oxlint: 70 warnings, 0 errors; build-client --check exits 0.
  • CI. 13 check runs, all completed: 12 success and the one expected enqueue-guard failure, do not enqueue #1342: no Verifier report names the tip ae5bf4e49b93; no cancelled run. Head has not moved (ae5bf4e4) and comments was 0 before this one.

Non-blocking notes:

  1. The fix is latent by design: nothing calls planJob yet (the slice (b) host writer is not in this tree), so the contract matters when that writer lands.
  2. A truthy non-string hold (42, true) is now read as no hold, not as an unnamed hold. The module header states the contract, but a caller must pass '<name>' or { by: '<name>' } when it means a hold.
  3. The local check cannot verify the pin line (no git on this machine); that line is taken from the package job log above. check-arch-docs likewise cannot run locally (no origin/main), and CI covers it.

@github-actions github-actions Bot added the pass label Oct 27, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 807e592 Oct 27, 2026
13 checks passed
@Birfy
Birfy deleted the dev2/1337-workflow-blank-hold branch October 27, 2026 05:54
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…hinkFlowLab#1348 prose (ThinkFlowLab#1350) (ThinkFlowLab#1352)

Owner: Dev
Reviewer: Reviewer
Fixes ThinkFlowLab#1350
Fixes ThinkFlowLab#1354
Tip SHA: a09f330

**Executor decision B: remove the frozen-branch seal whole, and keep
what `ThinkFlowLab#1350` asked for that is not about the seal.** `ThinkFlowLab#1354`'s repair —
read the list from `main`, list `ThinkFlowLab#1328`'s number in code — is on this
branch's earlier commits and is now moot: with no seal there is nothing
to read and no number to list.

## Reproduce

**The seal could not hold.** `ThinkFlowLab#1328` (branch
`dev4/1265-step2-one-board`, base `8d0a99c1`) entered the queue at
`06:01:1xZ`, was turned draft, and merged at `06:03:21Z` — the seal
never answered it: the base read found no document, and its branch
matched no listed prefix. `ThinkFlowLab#1350`/`ThinkFlowLab#1354` patched the reading so the
next such pull request would be refused. The owner then decided the
mechanism goes instead of being patched: the cost of the seal (a
queue-entry stop re-read on every `labeled` event, a policy document no
pull request may edit, a number list that had to live in code) was not
worth what it caught, and it never was a permission boundary — a direct
push, an admin merge or a renamed branch walked past it.

**The check that judges this pull request is `main`'s copy, and that
copy still carries the seal.** That is why the document is not deleted
here. The first push of this change (`8f97e4f5`) deleted
`.github/agent/frozen-branches.json` along with the code; the check run
at that head answered `do not enqueue ThinkFlowLab#1352: frozen:
.github/agent/frozen-branches.json is not editable by a pull request`.
`enqueue-guard.yml` checks out `ref: main` and never runs pull request
code, so any diff that deletes or edits that document is refused by the
seal until the seal itself is on `main`. Removing the reader and
removing the document cannot travel in one pull request; this one
removes the reader, keeps the document byte-identical to the previous
tip, and the deletion is the follow-up pull request.

## What changed

1. **`.github/scripts/enqueue-guard.mjs` — the whole path, not a branch
of it.** `FROZEN_PATH`, `FROZEN_REASON`, `FROZEN_EDIT_REASON`,
`FROZEN_PULL_REQUESTS`, `frozenList()`, `frozenRefusal()` and the
`decidePull` call site with its early return are gone; `decidePull`
returns `{ decision, pull, repo, pr }` from `guard()` alone. No dead
import, no dead export, no always-true branch left. `guard()`,
`recordOnlyAt` and every other rule are untouched.
2. **The nine cases that existed only for the seal are deleted** — the
five `ThinkFlowLab#1344` cases, the `ThinkFlowLab#1350` `merged`-short-circuit case (it pinned
the seal's own ordering and has no meaning without it) and the three
`ThinkFlowLab#1354` cases. The fixture loses the `/contents/` answer and the
`frozen`, `prNumber` and `baseSha` knobs and the `BASE` constant that
fed them: nothing reads them now, so none is left dangling. **No other
test was touched** — every remaining assertion is byte-identical.
3. **`AGENTS.md` — the section is gone.** `#### Untrusted external agent
branches` goes whole: the three `Do not` bullets, the
`dequeuePullRequest` paragraph, the "never the baseline for `generate`"
paragraph and the Chinese release sentence. What `ThinkFlowLab#1350` asked for that
is *not* about the seal stays: rule 7 now matches `accountRefusal()` —
only `type === 'User'` writes a conclusion, a report with **no `type` at
all** is refused too, the account and its text stay as a note in
`judged`, and the refusal's sentence stays `no Verifier report names the
tip <sha>`.
4. **`.github/agent/frozen-branches.json` is unchanged here** — `cmp`
against the previous tip is identical. Nothing reads it after this
change; it is deleted in the follow-up pull request, which is possible
only once `main` no longer carries the rule that refuses that deletion.
5. **`ThinkFlowLab#1354` is not needed any more.** Nothing is read from `main` or
from a base, so the base-vs-main defect cannot recur; the issue is
closed by this pull request as "the mechanism was removed", not by a
fix.

## What I ran

On `/data/w1352`, a tree extracted from the repository's own tarball of
this branch's previous tip `c6ba9a09`, with `git` 2.53.0 and its
`libexec/git-core` on `PATH`:

- `node --test ".github/scripts/*.test.mjs"` → **543 / 543 pass** (this
branch was 552 before this change; `−9` is exactly the nine cases
removed with the seal).
- `node .github/scripts/enqueue-guard.test.mjs` → **88 / 88 pass** (was
97; `−9` again).
- `node .github/scripts/testing-layers.mjs --check` → `440 test file(s)
in 16 workflow(s)`, every file and every workflow classified.
- `node .github/scripts/check-mojibake.mjs` → `ok — 0 occurrences of
U+FFFD in 1155 text file(s)`.
- `diff -rq` against `c6ba9a09` → exactly three files, all listed under
Scope. `cmp` on the JSON document: identical.
- `grep -rn 'frozen-branches\|frozenRefusal\|frozenList\|external agent
branch'` over `*.mjs`, `*.md`, `*.json`, `*.yml` → **0** (22 lines at
the previous tip). The document file exists but no text names it.
- GitHub's own read of the pull request: `3 files, +3 / −116`.

## Red without the source change

**A deletion has nothing that can go red.** The nine cases were removed
together with the code they pinned, so restoring the source would
restore the tests and the suite would be green either way — claiming a
red here would be a fabrication. The mechanical substitutes, each run:

1. **The grep.** The command above returns no line and exit status 1 (22
lines at the previous tip).
2. **The module's exports.** `import()` of `enqueue-guard.mjs` names
exactly `decidePull, guard, main, recordOnlyAt`; `frozenRefusal` is
`undefined`. The export does not exist to be called.
3. **The same fake `gh`, both tips, through the printed reader.** A pull
request on a listed prefix branch with no conclusion, run through
`main()`:
- previous tip: `REFUSE — do not enqueue ThinkFlowLab#566: frozen: external agent
branch; owner decision pending`
- this tip: `REFUSE — do not enqueue ThinkFlowLab#566: no Verifier report names the
tip aaaa…; no checkpoint: …`

The seal's sentence no longer appears in any decision; the pull request
is judged by the ordinary rules, which is the point of B.
4. **The one genuine red this change can show, and it did:** main's own
seal refused this pull request while its diff deleted the document — the
`8f97e4f5` run quoted under Reproduce. That red is real and is the
reason the document is not in this diff.

## Not run

- `npm run verify`: nothing under `core/`, `plugins/` or `apps/` is
touched.
- `generate` / `scripts/ds-bot-patches.mjs check`: `patches/` is
untouched.
- `check-arch-docs`: its rule reads `core/` changes; there are none.
- A live queue entry: the next real enqueue exercises `decidePull`; a
pull request cannot stage one without merging.

## Risk

- **The seal is gone, so its refusal is gone.** A branch under a listed
prefix, or a body carrying the agent's PR-body marker, is now judged by
the ordinary rules only; one that carries a PASS at its tip and a clean
body can enter the queue. That is the owner's decision, and the seal
never stopped a direct push or an admin merge anyway.
- **The document outlives its reader for one pull request.** Between
this merge and the follow-up, `.github/agent/frozen-branches.json` sits
in the tree with nothing reading it; nothing can act on it, and the
follow-up deletes it. Anyone reading the tree in that window should read
it as a stale config, not a live rule. Deleting the reader and deleting
the file cannot travel in one pull request: `enqueue-guard.yml` is
`pull_request_target` with `ref: main` and never runs pull request code,
so main's copy of the seal refuses any diff that edits or deletes the
document. That is a mechanical constraint of the check, not a choice
made here.
- **`ThinkFlowLab#1328` stays merged.** This removes the mechanism, not that merge;
nothing here reopens or reverts it, and `ThinkFlowLab#1346`'s branch is untouched.

## Scope

- `.github/scripts/enqueue-guard.mjs` — removal only.
- `.github/scripts/enqueue-guard.test.mjs` — removal of the nine seal
cases and of the fixture knobs they used.
- `AGENTS.md` — the external-agent section deleted; rule 7's
`accountRefusal()` alignment kept.

Unchanged, deliberately: `.github/agent/frozen-branches.json`. Not
touched: `core/`, `patches/`, `package*`,
`.github/workflows/pass-label.yml`, tier calculation, and every tip
belonging to `ThinkFlowLab#1328`, `ThinkFlowLab#1346`, `ThinkFlowLab#1348`, `ThinkFlowLab#1342`, `ThinkFlowLab#1349`.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 1352,
  "claim": "Dev",
  "issue": 1350,
  "summary": "Executor decision B: the frozen-branch seal is removed whole, not patched. enqueue-guard.mjs loses the four constants, frozenList(), frozenRefusal() and the decidePull call site; the nine cases that only pinned the seal go with the fixture knobs they used; the AGENTS.md external-agent section goes, while ThinkFlowLab#1350's rule-7 alignment stays. ThinkFlowLab#1354's fix is moot with no list to read. The document itself is not in this diff: main's own check refuses any pull request that edits it, so it stays byte-identical for a follow-up.",
  "files": [".github/scripts/enqueue-guard.mjs", ".github/scripts/enqueue-guard.test.mjs", "AGENTS.md"],
  "checks": { "ran": "node --test \".github/scripts/*.test.mjs\" (543/543); enqueue-guard 88/88; testing-layers --check; check-mojibake; grep for the seal names; cmp of the JSON document", "result": "green", "at": "a09f330ab2b298fc1095884a7f79dd086435c26f" },
  "next": "none — ready for review at this tip"
}
```
<!-- /flybot:checkpoint -->
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

workflow-run: a blank hold string is read as a hold, so every node is refused and the sentence may be missing

1 participant