Repository navigation
workflow: a Stop whose release always fails is retried for ever and keeps its lane slot - #2753
Conversation
|
@Birfy — ready for review at Only |
|
Advisory report at 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.
Readings, all taken locally on this machine at the tip above:
Not run, and why:
|
aa9fbdd to
f9e754e
Compare
|
@Birfy — review request at tip Why the head moved, and why nothing in it changed. Until now this pull request had only two check runs on its head ( The defect. The change. Red first on a clean Checks at this tip ( Tier T2 ( |
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.
|
Verdict: PASS at f9e754e Independent review at the tip above. I read the diff itself, checked the defect on Ran:
Not run:
What I checked independently:
Findings: no actionable findings for this T2 scope. One non-blocking note: 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 |
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
…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.
…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>
…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>
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.jsexpireStoppedRun: when a Stop's release failed, the deadline only re-armed itself for anotherstepTimeoutMinutes. There was no attempt cap and no terminal state, so a release the host always refuses looped for ever: the run stayedrunning+paused+releasePending, kept itsmaxConcurrentslot, 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 runfailedwithfailure = { 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 endspaused-expired, unchanged. The terminal record is written, and the run archived, beforedrainAll()— inside the samehostSerialstep — 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 addsreleaseRetries === 1and< 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, andrun.failureis documented withattempts?.changelog.d/2750-stop-release-cap.fixed.md— the user-visible entry.Red first
On a clean checkout of
f2ef4dfaccedd40315bd585474d0902118dc0ba8carrying only the finalplugins/workflow/test/stop-retry-deadline.test.js,cd plugins/workflow && node --import ./test/resolve.mjs --test test/stop-retry-deadline.test.js:Same shape on two consecutive runs.
Checks at this tree
The branch head
aa9fbdd9310a82971070aa8455174e3ea8ab87f9carries tree0d72c5b1d9eab9fe2f16b54f8b8a049c273e3d79— the same tree as the local commit533a24a153f49291b85bd19d81a1cd109cd293c5these 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.# 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— threeoklines;canonical—generateis 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 profileincore/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+policy671, interop 37, live-e2e 34.Not run: the smoke test (no
index.jsorcore/src/host/change), and CI'spackagejob (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"
}