Skip to content

workflow: a Stop whose release always fails is retried for ever and keeps its lane slot - #2753

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2750-workflow-a-stop-whose-release-always-fai
Nov 10, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2750-workflow-a-stop-whose-release-always-fai

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2750

Owner: Dev-3
Reviewer: Birfy
Written-by: Dev-3
Bot: Dev-3
Claim-Id: f2ed2646471c4eb28be9ee0f65dbfbfe
Base: f2ef4df
Risk tier: T2 (risk-gate: path plugins/workflow/src/service.js)

The defect

plugins/workflow/src/service.js expireStoppedRun: when a Stop's release failed, the deadline only re-armed itself for another stepTimeoutMinutes. There was no attempt cap and no terminal state, so a release the host always refuses looped for ever: the run stayed running + paused + releasePending, kept its maxConcurrent slot, and the cards queued behind it never started.

What changed

  • plugins/workflow/src/service.js — STOP_RELEASE_RETRIES (2) is exported and counted on the run (releaseRetries, also in the run projection). The deadline retries are bounded: the Stop's own attempt plus two deadline retries, three in all. The last one still failing ends the run failed with failure = { code: 'release-failed', text: 'stop could not release N Bot(s) in 3 attempts: <ids>', attempts: 3 }, so the ids the host would not remove stay named on the row and the lane slot comes back. The run that finally releases its Bot still ends paused-expired, unchanged. The terminal record is written, and the run archived, before drainAll() — inside the same hostSerial step — so a parked card never lands ahead of the row that freed its slot.
  • plugins/workflow/test/stop-retry-deadline.test.js — a release that always fails now ends the run at the cap and the card queued behind it starts; the existing re-arm case keeps its assertion and adds releaseRetries === 1 and < STOP_RELEASE_RETRIES, so the re-arm is a counted round, not an unbounded loop.
  • docs/subsystems/workflow.md — the paused-expired sentence now states the bound and the terminal state, and run.failure is documented with attempts?.
  • changelog.d/2750-stop-release-cap.fixed.md — the user-visible entry.

Red first

On a clean checkout of f2ef4dfaccedd40315bd585474d0902118dc0ba8 carrying only the final plugins/workflow/test/stop-retry-deadline.test.js, cd plugins/workflow && node --import ./test/resolve.mjs --test test/stop-retry-deadline.test.js:

# tests 7
# pass 5
# fail 2
not ok 2 - a deadline retry that still cannot release keeps the Bot named and arms the next retry
  the round that just failed is counted on the run
  undefined !== 1
not ok 7 - a release that never succeeds ends the run at the retry cap and frees the lane
  the cap freed the slot, so the parked card starts instead of waiting for ever
  '#2600' !== '#2606'

Same shape on two consecutive runs.

Checks at this tree

The branch head aa9fbdd9310a82971070aa8455174e3ea8ab87f9 carries tree 0d72c5b1d9eab9fe2f16b54f8b8a049c273e3d79 — the same tree as the local commit 533a24a153f49291b85bd19d81a1cd109cd293c5 these readings were taken on. The head is a different commit because the branch was created through the Git Data API, which re-signs it; the content is identical.

  • the same file at this tree: # tests 7 / # pass 7 / # fail 0, twice.
  • cd plugins/workflow && node --import ./test/resolve.mjs --test "test/*.test.js" — # tests 509 / # pass 509 / # fail 0.
  • ARCH_DOCS_BASE=f2ef4dfa npm --prefix core run lint — oxlint 0 errors, arch-docs: OK (4 changed file(s)).
  • node scripts/ds-bot-patches.mjs check — three ok lines; canonical — generate is a no-op; node core/scripts/build-client.mjs --check — exit 0.
  • node .github/scripts/changelog.mjs check — 17 fragments well formed.
  • TZ=UTC ARCH_DOCS_BASE=f2ef4dfa npm run verify — exit 1, one failure only: bench.sh shows a failed pnpm install and leaves no half-built profile in core/test/bench-preflight.test.js (bench.sh: 91: mkdir: not found). That is the host-fixture bug dev: refuse a bench start without pnpm or the harness native addon #2580, unrelated to this change. Core suite # tests 3136 / # pass 3135 / # fail 1; the suites after it were run by hand and are green: social 619, tree 140, timeline 161, workflow 509, .github/scripts + policy 671, interop 37, live-e2e 34.

Not run: the smoke test (no index.js or core/src/host/ change), and CI's package job (pack check, typecheck, actionlint); CI runs those on this tip.

Checkpoint

{
"pr": 2753,
"claim": "Dev-3",
"issue": 2750,
"summary": "A Stop whose release kept failing only re-armed its deadline, so the run stayed paused and releasePending, held its maxConcurrent slot and the cards behind it never started. The deadline retries are counted on the run now: after two more deadlines the last attempt ends the run failed as release-failed, with the ids the host would not remove and the attempt count on the row, so the slot comes back and the queue drains. The run that finally releases its Bot still ends paused-expired, unchanged.",
"files": ["plugins/workflow/src/service.js", "plugins/workflow/test/stop-retry-deadline.test.js", "docs/subsystems/workflow.md", "changelog.d/2750-stop-release-cap.fixed.md"],
"checks": { "ran": "cd plugins/workflow && node --import ./test/resolve.mjs --test "test/*.test.js" (509/509); ARCH_DOCS_BASE=f2ef4dfa npm --prefix core run lint; node scripts/ds-bot-patches.mjs check; node scripts/ds-bot-patches.mjs canonical; node core/scripts/build-client.mjs --check; node .github/scripts/changelog.mjs check; TZ=UTC ARCH_DOCS_BASE=f2ef4dfa npm run verify (exit 1, only the known #2580 host fixture)", "result": "red", "at": "533a24a153f49291b85bd19d81a1cd109cd293c5" },
"next": "none — waiting for the Verdict at aa9fbdd",
"tier": "T2"
}

@HugoXK

HugoXK commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy — ready for review at aa9fbdd9310a82971070aa8455174e3ea8ab87f9 (T2, plugins/workflow/src/service.js).

Only agent-audience has run on this tip; the gate checks (tests-ok and the guard row) are missing, so please approve the workflows for this sha as well.

@HugoXK

HugoXK commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Advisory report at aa9fbdd9310a82971070aa8455174e3ea8ab87f9 (issue #2750).

This is an independent local self-test. It does not count as a reviewer verdict and it is not the T2 PASS for this PR; that verdict still has to come from the repository reviewer. No merge permission is implied.

  • Tip: aa9fbdd9310a82971070aa8455174e3ea8ab87f9. REST re-read at Date: Mon, 09 Nov 2026 10:56:12 GMT; fork branch dev/2750-workflow-a-stop-whose-release-always-fai at the same sha (Date: 10:56:20 GMT).
  • Base: f2ef4dfaccedd40315bd585474d0902118dc0ba8; 4 files, +70/−10; ready; maintainer_can_modify: true.

Readings, all taken locally on this machine at the tip above:

  • Red-first, clean base (f2ef4dfa, only this PR's plugins/workflow/test/stop-retry-deadline.test.js dropped in, reverted to 0 tracked changes afterwards): 7 tests / 5 pass / 2 fail — :141 a deadline retry that still cannot release → undefined !== 1; :286 a release that never succeeds ends the run at the retry cap → '#2606' !== '#2600' (the queued card never starts because the slot is never freed).
  • This PR's own test file at the tip: 7/7 pass.
  • plugins/workflow full suite at the tip: 509/509 pass.
  • TZ=UTC ARCH_DOCS_BASE=f2ef4dfa npm run verify: exit 0 — 3136+619+140+161+509+671+37+34 = 5307 assertions, 0 failures, with arch-docs: OK (4 changed file(s)); changelog check reads 17 fragments, well formed.

Not run, and why:

  • The CI tests-ok suite has never run on this fork PR (the workflow suite is not yet approved for it), so there is no tests-ok reading to cite. Everything above is a local reading, not CI.
  • Without a PATH shim in front, verify's only red is the known #2580 host-fixture failure, bench.sh: 91: mkdir: not found — the fixture filters out /usr/bin, which carries coreutils along with pnpm. It reproduces identically on the clean base, so it is environmental and not caused by this change.

Ran: double-checked tip (REST PR + fork branch ref, both with response Date) → red-first on clean base (PR test file only, 0 tracked after revert) → PR test file at tip → plugins/workflow full suite → TZ=UTC ARCH_DOCS_BASE=f2ef4dfa npm run verify (exit 0) → changelog check.

@github-actions github-actions Bot added the risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff label Nov 9, 2026
@HugoXK
HugoXK force-pushed the dev/2750-workflow-a-stop-whose-release-always-fai branch from aa9fbdd to f9e754e Compare November 10, 2026 01:43
@HugoXK

HugoXK commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy — review request at tip f9e754eaa63a324e80eb3f2e2e287c792bac71ee (one request per tip; the previous request named aa9fbdd9…).

Why the head moved, and why nothing in it changed. Until now this pull request had only two check runs on its head (agent-audience, enqueue-guard) and no tests-ok at all, so there was nothing to review against. The head was re-committed with --amend --no-edit (committer timestamp only) to re-trigger pull_request; the commit message, the author, the diff and the PR body are untouched, and the tree is byte-identical: 0d72c5b1d9eab9fe2f16b54f8b8a049c273e3d79 before and after. It is still one commit on f2ef4dfa, 4 files +70/−10.

The defect. plugins/workflow/src/service.js expireStoppedRun: when a Stop's release failed, the deadline only re-armed itself for another stepTimeoutMinutes. No attempt cap, no terminal state — a release the host always refuses looped for ever: the run stayed running + paused + releasePending, kept its maxConcurrent slot, and the cards queued behind it never started.

The change. STOP_RELEASE_RETRIES (2) is exported and counted on the run (releaseRetries, also in the run projection); the Stop's own attempt plus two deadline retries, three in all. The last one still failing ends the run failed with failure = { code: 'release-failed', text: 'stop could not release N Bot(s) in 3 attempts: <ids>', attempts: 3 } — the ids the host would not remove stay named on the row, and the lane slot comes back. A run that finally releases ends paused-expired, unchanged. The terminal record is written and the run archived before drainAll(), inside the same hostSerial step, so a parked card never lands ahead of the row that freed its slot.

Red first on a clean f2ef4dfa carrying only the final test file: # tests 7 / # pass 5 / # fail 2 — not ok 2 the round that just failed is counted on the run (undefined !== 1), not ok 7 the cap freed the slot, so the parked card starts instead of waiting for ever ('#2600' !== '#2606'), same shape twice. At this tree: the file 7/7 (twice), the whole plugin suite 509/509, lint 0 errors with arch-docs: OK (4 changed files), ds-bot-patches check and canonical green, build-client --check exit 0.

Checks at this tip (f9e754ea…): 17 runs = 14 success + 2 skipped + 1 failure. The one failure is enqueue-guard — no Verifier report names the tip f9e754ea324e — which is what this request is for; tests-ok, merge-gate, policy-sign, judge, package, plugins (workflow|tree|social|timeline) and mock · ubuntu · Node 24 · scripted model are all green. The two skipped are live · ubuntu · Node 24 · real model and window check.

Tier T2 (plugins/workflow/src/service.js), so this needs the Verdict naming this tip before it can be enqueued.

A Stop whose release keeps failing only re-armed its deadline, so the run stayed
`paused` + `releasePending`, held its `maxConcurrent` slot for ever and the cards
queued behind it never started (ThinkFlowLab#2750). The retries are counted on the run now:
after `STOP_RELEASE_RETRIES` (two) more deadlines, the last attempt ends the run
`failed` as `release-failed`, with the ids the host would not remove and the
attempt count on the row, so the slot comes back and the queue drains. The
subsystem doc states the same bound and the widened `run.failure` shape, and a
changelog fragment records it.
@Birfy

Birfy commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at f9e754e
Written-by: Reviewer

Independent review at the tip above. I read the diff itself, checked the defect on f2ef4dfa, and ran the changed test both at the tip and against the base source carrying only the tip's test file.

Ran:

  • node /data/dsh/bot/workspace/p2753/dl.mjs — GitHub tarballs of f9e754eaa63a324e80eb3f2e2e287c792bac71ee (tip) and f2ef4dfaccedd40315bd585474d0902118dc0ba8 (base) unpacked; diff -rq base tip lists exactly 4 paths — the 3 named in the body plus the new changelog fragment.
  • node /data/dsh/bot/workspace/p2753/diff.mjs — GET /repos/ThinkFlowLab/FlyBot/pulls/2753 with Accept: application/vnd.github.v3.diff → 4 files, +70/−10, the same 4 paths.
  • cd plugins/workflow && node --import ./test/resolve.mjs --test test/stop-retry-deadline.test.js (tip tree) → # tests 7 / # pass 7 / # fail 0.
  • cp -r base red && cp tip/plugins/workflow/test/stop-retry-deadline.test.js red/plugins/workflow/test/stop-retry-deadline.test.js && cd red/plugins/workflow && node --import ./test/resolve.mjs --test test/stop-retry-deadline.test.js (base source, tip test) → # tests 7 / # pass 5 / # fail 2, twice with the same shape: not ok 2 the round that just failed is counted on the run undefined !== 1, and not ok 7 the cap freed the slot, so the parked card starts instead of waiting for ever '#2600' !== '#2606'.
  • cd plugins/workflow && node --import ./test/resolve.mjs --test "test/*.test.js" (tip tree) → 508 tests, 507 pass, 1 fail: test/host.test.js dies with ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-llm' because this tarball checkout has no core/dev/harness install. The same file fails identically on the base tree, so it is the checkout rather than this change; CI's plugins (workflow) job is green at this tip.
  • node .github/scripts/changelog.mjs check (tip tree) → changelog: 17 fragment(s) in changelog.d/ are well formed.
  • node .github/scripts/enqueue-guard.mjs --pr 2753 (tip tree, shim gh) → it refuses with one reason only, no Verifier report names the tip f9e754eaa63a…, and nothing else; that single missing read is what this report supplies.

Not run:

  • TZ=UTC npm run verify and npm --prefix core run lint (arch-docs needs a git checkout and the core deps/harness are not installed here) — CI's tests-ok, package and plugins (workflow) are green at this tip.
  • the smoke test — no core/index.js or core/src/host/ change.
  • the real-model live job — CI skips it for this change too.

What I checked independently:

  • The defect is real on f2ef4dfa. expireStoppedRun re-armed the deadline whenever failed.length > 0, with no counter and no terminal state, so a release the host always refuses loops and the run keeps its maxConcurrent slot; issue workflow: a Stop whose release always fails is retried for ever and keeps its lane slot #2750's probe shows four rounds still running, slot=1, queue=1.
  • The fix caps exactly that path. STOP_RELEASE_RETRIES = 2 is exported and counted on the run as releaseRetries; the second deadline retry has retries === STOP_RELEASE_RETRIES, falls through to endRun with code: 'release-failed', text naming the ids the host would not remove, and attempts: retries + 1 (the Stop's own attempt plus the two deadline retries = 3). endRun clears paused/releasePending, then archiveCutoffRun writes the row and write() persists it before drainAll() runs in the same hostSerial step, so the parked card starts behind the row that freed the slot. A run whose Bot finally goes still ends paused-expired on the same code path as before.
  • The failed ids stay named on the archived lane.last run (they are not deleted in the cap branch), so the next load's reap still retries the removal — the fix(workflow): keep a run Bot's id when its release fails #1616 contract is unchanged.
  • The projected field and the doc agree: publicRun carries releaseRetries only when set, and docs/subsystems/workflow.md now states the bound, the terminal release-failed state and run.failure.attempts; that page is the owned doc for plugins/workflow/, so arch-docs has its same-PR update. The changelog fragment is well formed.
  • No other path changed: nothing under policy/, .github/scripts/, .github/workflows/ or patches/ds-bot/ is touched; no DSH patch, no secret, no test pointed at real DSH data.

Findings: no actionable findings for this T2 scope. One non-blocking note: attempts counts the Stop's own attempt plus the deadline retries, so a second manual Stop that fails before the last deadline is not added to that number — the cap is still monotone and bounded, and the count on the row reads as written.

Security fence / display names: not in scope — this diff touches no delivery, social, fencing or model-visible remote text, and adds no rendering of a remote name.

This report is a review record, not a queue permit: the enqueue stays the gating agent's own step, and merge-gate re-judges the queue entry.

@github-actions github-actions Bot added the pass label Nov 10, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 10, 2026
Merged via the queue into ThinkFlowLab:main with commit 7adb4a5 Nov 10, 2026
17 checks passed
@Birfy
Birfy deleted the dev/2750-workflow-a-stop-whose-release-always-fai branch November 10, 2026 05:15
Birfy pushed a commit to HugoXK/FlyBot that referenced this pull request Nov 10, 2026
Rebased onto 7b7f6c4 (main after ThinkFlowLab#2753/ThinkFlowLab#2750) by the rework line, because
`PUT /pulls/2757/update-branch` refused the branch with 422 "merge conflict
between base and head".

One conflict, and it is textual only:
- `docs/subsystems/workflow.md`: both sides edited the same paragraph 3 line.
  main's ThinkFlowLab#2750 wording is kept (STOP_RELEASE_RETRIES / `release-failed`,
  `attempts?` on `run.failure`) and this branch's drain sentence is added
  after "Nothing resumes it" (ThinkFlowLab#2752, ThinkFlowLab#2757). Nothing is dropped from either.
- `plugins/workflow/src/service.js` merged cleanly: main's ThinkFlowLab#2750 changes sit
  in `expireStoppedRun` / `publicRun`, this branch's change only in `reap()`.
- the test and the changelog fragment are the branch's own files, taken as-is.

Substantive change carried: `reap()` drains the lane's queue after the reaper
settles, on the `hostSerial` tail every `assign` takes (ThinkFlowLab#2752 + ThinkFlowLab#2757).

Original tip: 985d7d2
Birfy added a commit to HugoXK/FlyBot that referenced this pull request Nov 10, 2026
…off-tail deadline

main moved to 7b7f6c4 while this branch sat on f2ef4df; ThinkFlowLab#2753 changed the same
region of plugins/workflow/src/service.js (the step timeouts, expireStoppedRun and
the stop-release cap) and ThinkFlowLab#2750 changed the same deadline paragraph in
docs/subsystems/workflow.md. Both sides are kept:

- service.js: HANDOFF_RETRY_DELAYS_MS and STOP_RELEASE_RETRIES (with the
  releaseRetries failure path in expireStoppedRun) stay from main, and
  SPAWN_DEADLINE_MS plus the off-tail expireStep and startsInFlight count sit
  beside them. No line of either change was dropped.
- docs/subsystems/workflow.md: the stop-release retry sentence (ThinkFlowLab#2750) and the
  deadline-off-the-tail plus 60 s start bound sentences (ThinkFlowLab#2748) are both there.
- the two new test files and the changelog fragment are unchanged from a79976f.

Verified on this tree: test/tail-hang.test.js + test/cap-concurrent-drain.test.js
+ test/stop-retry-deadline.test.js => 11/11 pass; the ThinkFlowLab#2750 tests still pass with
the ThinkFlowLab#2748 changes and the ThinkFlowLab#2748 probes still pass with ThinkFlowLab#2750 present.
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Nov 10, 2026
…ThinkFlowLab#2773)

Fixes ThinkFlowLab#2748

Owner: Dev-3
Reviewer: Birfy
Written-by: Dev-3
Bot: Dev-3
Claim-Id: 9df438ed10ac478b8d037965974377ec
Base: f2ef4df
Risk tier: T2

## The defect

The step deadline is the one thing that bounds every wait in a live run,
and `expireStep()` ran the whole expiry inside `hostSerial`. The serial
tail is exactly where a host call that never answers sits: `dispatch`,
`release` and `spawn` are each awaited there. So a host that never
answered parked the tail for ever, the timer that ends the run fired and
queued behind it, and the run stayed `running` with its Bots still held
and its lane slot never freed. A Bot start was worse off: `takeNext()`
had already shifted the card out of `lane.queue`, so a mute host left
the card neither running nor queued — it was simply gone.

## What changed

- `plugins/workflow/src/service.js` — `expireStep()` no longer runs
inside `hostSerial` (`return hostSerial(async () => {…})` becomes
`return (async () => {…})()`), the one-line shape the issue names; the
body still ends with `await drainAll()`. A deadline can no longer be
queued behind the wait it bounds, so a dispatch or a release that never
answers still ends the run `failed`, releases its Bots and clears its
timers.
- `SPAWN_DEADLINE_MS` (60 s) and `boundedSpawn()`: a start whose Bots
the host does not create within that bound is refused with `code:
'spawn-timeout'`, and a late answer's Bots are released instead of
leaked. `startLive()` wraps `spawnAll(…)` in it, so `drainAll()` takes
the existing `started.ok === false` path and unshifts the card it had
taken back to `lane.queue`.
- `plugins/workflow/test/tail-hang.test.js` (new) — three probes on a
fake clock: C1 a dispatch that never settles, C2 another workflow's
release that never settles, C4 a spawn that never answers. C1/C2 assert
the run and node end `failed` with Bots released and no timer left; C4
asserts the call answers, the queue still holds the card behind it, and
the lane is idle.
- `plugins/workflow/src/service.js` — a start that is still waiting on
the host now counts against its lane's `maxConcurrent` (Test's advisory
on this PR): two steps ending in the same turn each run `drainAll()`,
the first start is parked on the host and `takeNext()` counts only begun
runs, so the second drain takes a second card and the lane reaches
`activeCount 2` at `maxConcurrent 1`. The count is `startsInFlight`, one
number per workflow, never persisted.
- `plugins/workflow/test/cap-concurrent-drain.test.js` (new) — that
case: lane cap 1, a live run plus two queued cards, both deadlines in
the same turn, every spawn held. It asserts the lane never passes
`maxConcurrent`, the card the cap kept out stays in the queue, and it
starts once the slot frees.
- `docs/subsystems/workflow.md` — the deadline paragraph now states that
the expiry is not queued behind the wait it bounds, and names the 60 s
start bound; the test list names the three hangs.
- `changelog.d/2748-workflow-stuck-run.fixed.md` — the user-visible
entry.

## The race the advisory found, and the fix

Removing `hostSerial` from `expireStep()` let two drains reach one lane
in the same turn, and `takeNext()` counts only runs `beginRun()` has
already made visible. The first drain was still parked in `await
boundedSpawn(spawnAll(…))` when the second looked for a card, so the
second read the lane as empty and started a card too. Probe reading at
`cc35bf48`, 6/6:

```
# PROBE S3 cap under concurrent drain: FAIL — activeCount=2 (3 live run(s): #S3b,#S3c,#S3a) at maxConcurrent 1
```

The fix counts a start in flight: `takeNext()` and `hasRoom()` add
`startsOf(workflow)` to `runningOf(lane).length`, and `startLive()`
gives that count back the moment `beginRun()` puts the run where
`runningOf()` sees it — and on every way out that never gets that far (a
refused start, a spawn timeout, a missing provider). Holding it through
the hand-off instead would count a slot nothing is using, and a dispatch
that never answers would hold it for good — so the release sits at
`beginRun()`, not after `startLive()` returns.

## Red first

At the live base `f2ef4dfa` with only the test file added, twice in a
row with the same shape — `# tests 3 / # pass 0 / # fail 3`:

```
# PROBE C1 status: running node: running active: 1 released: 0 timersLeft: 0
not ok 1 - ThinkFlowLab#2748 C1: a dispatch that never settles still reaches failed at its step deadline
  the deadline ends the run even while the dispatch hangs
  + 'running' - 'failed'
  TestContext.<anonymous> (tail-hang.test.js:116:10)

# PROBE C2 alpha: running bravo before: running after its deadline: running bravo node: running released: 0 timersLeft: 0
not ok 2 - ThinkFlowLab#2748 C2: a release that never settles does not starve another workflow's deadline
  the other workflow still reaches its deadline
  + 'running' - 'failed'
  TestContext.<anonymous> (tail-hang.test.js:165:10)

# PROBE C4 settled: false queue: 0 active: 0 card: none
not ok 3 - ThinkFlowLab#2748 C4: a spawn that never answers keeps the queued card instead of losing it
  the call answers instead of hanging for ever
   false !== true
  TestContext.<anonymous> (tail-hang.test.js:201:10)
```

The base probes say the whole bug in one line each: C1 and C2 keep the
run `running` with `released: 0`, and C4's card is `none` — neither
running nor queued.

The race has its own red at `cc35bf48` — only
`cap-concurrent-drain.test.js` added, twice with the same shape, `#
tests 1 / # pass 0 / # fail 1`:

```
not ok 1 - ThinkFlowLab#2748 S3: two drains that reach one lane in the same turn keep it under maxConcurrent
  one run at maxConcurrent 1, not 2
  TestContext.<anonymous> (cap-concurrent-drain.test.js:92:1)
```

## Verification at the tip

Tip SHA: `a79976fddd7599ab32b0615c0595aea051305820` (tree
`ffa6635fd9224e38363cf1faa59e4b22f8e113b6`)

Ran:

- `cd plugins/workflow && node --import ./test/resolve.mjs --test
test/tail-hang.test.js` → `# tests 3 / # pass 3 / # fail 0`, twice in a
row, same shape, with the PROBE lines C1 `status: failed node: failed
active: 0 released: 2 timersLeft: 0`, C2 `alpha: failed bravo before:
running after its deadline: failed bravo node: failed released: 2
timersLeft: 0`, C4 `settled: true queue: 1 active: 0 card: ThinkFlowLab#2748-C4b`.
- `cd plugins/workflow &&
DSH_HARNESS=/home/xuke/.local/lib/node_modules/@deepseek-ai/dsh node
--import ./test/resolve.mjs --test "test/*.test.js"` → `# tests 512 / #
pass 512 / # fail 0` (511 before this commit, plus the new case).
- `cd plugins/workflow && node --import ./test/resolve.mjs --test
test/cap-concurrent-drain.test.js` → `# tests 1 / # pass 1 / # fail 0`
at this tip.
- `node audit/probes/probe-2773.mjs <root>` → all five probes PASS, exit
0, S3 `activeCount=1 (2 live run(s): #S3b,#S3a) at maxConcurrent 1`,
queue 1.
- `ARCH_DOCS_BASE=f2ef4dfa node core/scripts/check-arch-docs.mjs` →
`arch-docs: OK (4 changed file(s))`.
- `oxlint -c core/.oxlintrc.json plugins/workflow/src/service.js
plugins/workflow/test/tail-hang.test.js
plugins/workflow/test/cap-concurrent-drain.test.js` → 0 errors, 8
warnings, all eight pre-existing `unicorn(...)` hits on lines this diff
does not touch (321, 324, 327, 364, 704, 717, 928, 2306).
- `node .github/scripts/check-empty-catch.mjs` → exit 0 (the first tip
failed this one; see below).
- `node .github/scripts/changelog.mjs check` → 17 fragments well formed.

Not run (and why): root `npm run verify` — this fresh clone has no
`core/node_modules` and `npm ci` cannot reach a registry from this box
(the plugin suite ran with `DSH_HARNESS` pointed at the packaged DSH),
so the steps above were run individually; the core suite (no core file
changed); and the smoke bench (the diff touches no host route, turn or
delivery path).

## The first tip's `package` red, and what moved

The first tip `cfc38464` failed the `package` job at step 11 ("Tests of
the repo scripts"): `.github/scripts/check-empty-catch.test.mjs` (ThinkFlowLab#213)
listed `plugins/workflow/src/service.js:412` — the `.catch(() => {})` on
the late answer's release chain, which dropped a throw from that chain
with nothing on record. `cc35bf48` gives that handler the file's own
shape: it warns with the reason, and `releaseAll` still warns once per
Bot it could not remove. The `typeof release === 'function'` ternary in
front of `releaseAll` went with it — dead, because `releaseAll` already
answers "no release function" with an empty list. No behaviour on the
C1/C2/C4 paths changed: the three probes and the 511-case suite were
re-read at `cc35bf48` and are green above.

## Ordering fact, stated plainly

`plugins/workflow/src/service.js` carried two other open pull requests
(ThinkFlowLab#2753, ThinkFlowLab#2757) while this was prepared. Live main was re-read before
this branch was cut and was still `f2ef4dfa`, so the branch was cut from
`f2ef4dfa` itself and every reading above was taken on that base; no
rebase was needed and none was done.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 2773,
  "claim": "Dev-3",
  "issue": 2748,
  "summary": "The step deadline ran inside hostSerial, the same tail an unanswered host call parks for ever, so a mute host left the run running with its Bots held and its slot never freed. The expiry now runs off the tail, a start whose Bots the host does not create within 60 s is refused, and a card a drain had taken goes back to the queue. Removing the tail exposed a race: two drains in one turn both read one lane as free. A start now counts against its lane's cap until beginRun() makes the run visible. Red 0/3 at the base; three probes, the concurrent-drain case and plugins/workflow are green, 512/512.",
  "files": ["plugins/workflow/src/service.js", "plugins/workflow/test/tail-hang.test.js", "plugins/workflow/test/cap-concurrent-drain.test.js", "docs/subsystems/workflow.md", "changelog.d/2748-workflow-stuck-run.fixed.md"],
  "checks": { "ran": "cd plugins/workflow && node --import ./test/resolve.mjs --test test/tail-hang.test.js test/cap-concurrent-drain.test.js; DSH_HARNESS=<dsh> node --import ./test/resolve.mjs --test \"test/*.test.js\" (512/512); node audit/probes/probe-2773.mjs <root> (5/5 PASS); node .github/scripts/check-empty-catch.mjs; ARCH_DOCS_BASE=f2ef4dfa node core/scripts/check-arch-docs.mjs", "result": "green", "at": "a79976fddd7599ab32b0615c0595aea051305820" },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->

---------

Co-authored-by: Dev-3 <dev-3@flybot.local>
Co-authored-by: Danyang Chen <birfychen@gmail.com>
Birfy pushed a commit to HugoXK/FlyBot that referenced this pull request Nov 10, 2026
…kFlowLab#2757)

Fixes ThinkFlowLab#2752

Owner: Dev-3
Reviewer: Birfy
Written-by: Dev-3
Bot: Dev-3
Claim-Id: 360153fdbc2b4b5bbbe89b65f7dcd891
Base: f2ef4df

## The defect

A card queued behind a run that ends **failed** never started. The lane
reported `activeCount 0` / `queueDepth 1` and sat there until some later
`assign` happened to drain it — the report's `wf-3161899a` waited nine
minutes and only a re-assign moved it.

The drain lived on the finish paths (`afterStep`, the deadline, Stop,
Reset) and not on the ending the report actually hit: `host-ended`. A
host that dies leaves the run `running` in `workflows.json`; the next
load's `reap()` ends it `host-ended`, releases its step Bots, and
stopped there. That last step is a terminal transition like any other,
so the queue is what it has to move.

## What changed

- `plugins/workflow/src/service.js` — `reap()` drains the lane's queue
after the reaper settles, so a card parked behind a run this reap ended
starts on load instead of waiting for the next assign. The drain runs
**on `hostSerial`**, the tail every assign takes: the load fires the
reap and does not await it (`index.js`), so a drain outside that tail
and a concurrent `assign` both read `runningOf` before either `beginRun`
lands, each sees the same free slot and starts a card — two runs on a
lane at cap 1 (advisory FAIL on the previous tip; repro below). **Not**
in the takeover reap: that one is a detached promise started inside
`writable()`, so a drain there would fight the very `assign` that
triggered the takeover — `takeover.test.js` F2 caught that (active 2,
queue 0) while this fix was in `reapRuns`. `ThinkFlowLab#2704` keeps its rule: the
takeover reap ends the dead window's run and leaves the queue to the
next drain, which is that same assign.
- `plugins/workflow/test/queued-card-starts-after-failed-run.test.js` —
two tests. (1) two cards on one lane at `maxConcurrent 1`; the first is
left `running` in a real store, a second window loads it and calls
`reap()`: the second card's run exists and `queueDepth` is 0. (2) the
same store, but the reap is **not awaited** and an `assign` for `#C` is
called in the same tick: asserts one run at cap 1 and that the two cards
are one run plus one queued.
- `docs/subsystems/workflow.md` — the load-time reap sentence says the
ending drains the queue, on the assigns' serial tail.
- `changelog.d/2752-queued-card-after-failed-run.fixed.md` — the
user-visible entry.

## Verification at the tip

Tip SHA: `0a7d2003d5e7354ea10c5d126ef7c41ccf86ea89`

Ran:
- `cd plugins/workflow && DSH_HARNESS=… node --import ./test/resolve.mjs
--test "test/*.test.js"` → **510 tests / 510 pass / 0 fail**
- `cd core && DSH_HARNESS=… node --import ./test/harness-resolve.mjs
--test --test-concurrency=4 "test/*.test.js"` → **3134 pass / 2 fail**,
both `core/test/bench-preflight.test.js` (`bench.sh refuses before
writing the profile when pnpm is missing`, `bench.sh shows a failed pnpm
install and leaves no half-built profile`). Those two are the fixture's
own false red (`mkdir: not found` / `cat: not found` at
`bench.sh:91`/`:53`, the PATH the fixture hands the script drops
coreutils) — untouched by this change, reproduced on the clean
`f2ef4dfa` tree, and tracked as its own ticket.
- `plugins/social` 619/619, `plugins/tree` 140/140, `plugins/timeline`
161/161 (same invocation with `DSH_HARNESS` set).
- `node core/scripts/lint.mjs` → oxlint 0 errors, `arch-docs: OK`; `node
scripts/lint-plugins.mjs` over `plugins/workflow` → 0 errors.
- `node core/scripts/build-client.mjs --check` ok, `node
scripts/ds-bot-patches.mjs check` ok, `node
.github/scripts/changelog.mjs check` ok.

Not run (and why): root `npm run verify` as one command — `npm ci`
cannot reach the registry on this box (`404 tarball`, and npm cannot
write `~/.npm/_logs`), so the steps above were run individually against
a harness install.

## Red first

**(1) The reported defect**, on a clean
`f2ef4dfaccedd40315bd585474d0902118dc0ba8` with only
`plugins/workflow/test/queued-card-starts-after-failed-run.test.js`
added and the source change reverted:

```
# tests 1
# pass 0
# fail 1
not ok 1 - a card queued behind a run the host ended failed starts after the reap
  the parked card did not start after the failed run ended
  + []
  - [ '#B' ]
```

**(2) The advisory FAIL** — same store, `reap()` not awaited,
`assign('#C')` in the same tick — on the previous tip `9964ce73` (drain
present, outside `hostSerial`), source reverted to it, test present:

```
not ok 2 - a load-time reap racing an assign never starts more than the lane cap
  the lane started two cards at cap 1
# tests 2
# pass 1
# fail 1
```

Same shape on two consecutive runs in both cases, and the plugin suite
is 510/510 with the change in place. Neither `if (test)` nor a relaxed
assertion is involved: both tests drive a real store through a second
service instance and call the public `reap()` / `assign()`.

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
  "pr": 2757,
  "claim": "Dev-3",
  "issue": 2752,
  "summary": "A card queued behind a failed run never started: the drain ran only on the finish paths, so a run a dead host left running, ended 'host-ended' by the load-time reap, left the queue parked with a free slot. The load-time reap now drains the queue once it settles, on the same hostSerial tail as an assign: the load does not await the reap, and outside that tail the drain and a concurrent assign each start a card at cap 1. Two tests cover it: the parking and the race.",
  "files": ["plugins/workflow/src/service.js", "plugins/workflow/test/queued-card-starts-after-failed-run.test.js", "docs/subsystems/workflow.md", "changelog.d/2752-queued-card-after-failed-run.fixed.md"],
  "checks": { "ran": "cd plugins/workflow && node --import ./test/resolve.mjs --test \"test/*.test.js\"", "result": "green", "at": "0a7d2003d5e7354ea10c5d126ef7c41ccf86ea89" },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->

## Rebased onto main (rework line)

`PUT /pulls/2757/update-branch` refused this branch twice (422 "merge
conflict between base and head"), so it was replayed by hand, keeping
main's new content:

- first onto `7b7f6c47` (ThinkFlowLab#2753/ThinkFlowLab#2750): one line of
`docs/subsystems/workflow.md`;
- then onto `b89ea5ec` (ThinkFlowLab#2773 / ThinkFlowLab#2748, which rewrote
`plugins/workflow/src/service.js`): main's ThinkFlowLab#2748 wording is kept in that
same paragraph and this branch's drain sentence is added after "Nothing
resumes it"; `service.js` did not overlap — ThinkFlowLab#2748 left `reap()` as it
was.

The change itself is unchanged; the diff is still the same 4 files,
+104/−7.

Tip is now `0a7d2003d5e7354ea10c5d126ef7c41ccf86ea89` — the Verdict must
name it.

---------

Co-authored-by: FlyBot Dev-3 <dev-3@flybot.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

workflow: a Stop whose release always fails is retried for ever and keeps its lane slot

2 participants