Repository navigation
Commit 6387793
fleet-write: the executor applies write-pace rule ⑤ to a stroke of 5+ content-creating actions, and the seat budgets for it (#24810)
Fixes #24755
Clause-②: no
## What this changes
The relay executor (`scripts/pm/fleet-write/execute.mjs`) now applies
write-pace rule ⑤ itself. When one stroke carries
`DEFAULT_BATCH_THRESHOLD` (5) or more content-creating actions, it
announces ONE batch on the runner (`announceBatch`, kind `fleet-write
stroke`) after the sender gate and before the first write. The writes
from the first through the last content-creating one then leave
`DEFAULT_BATCH_GAP_MS` (30 s) apart instead of rule ①'s 3 s. Below the
threshold nothing changes.
Seat side (`scripts/pm/fleet-write/dispatch.mjs`). The packer, the
run-poller and the executor all read the same definitions:
- `CONTENT_CREATING_OPS` is derived from the op table by
`createsContent`, never listed. It is an op whose first request is a
REST `POST` carrying prose (`title` / `body`), or one that creates or
moves a card (`ANNOTATED_OPS`). Today that is `comment`, `issue_create`,
`pr_create` and `transfer`. Edits, label / assignee / reviewer writes,
the ready / auto-merge flips, the base sync and the workflow dispatch do
not count.
- `strokePacing(payload)` returns:
- the stroke's content-creating count and its write count;
- the batch: the writes through the last content-creating one;
- the pacing: (batch writes − 1) × `DEFAULT_BATCH_GAP_MS`;
- the budget.
- The budget is `RELAY_JOB_TIMEOUT_MS` less `DEFAULT_CEILING_MS`:
- `RELAY_JOB_TIMEOUT_MS` mirrors the relay job's `timeout-minutes: 10`.
The self-test pins it to `.github/workflows/fleet-write.yml`.
- `DEFAULT_CEILING_MS` (5 min) is the allowance an ordinary stroke
already has from dispatch to completion.
- That leaves 5 min of pacing, so one stroke holds at most 11 batched
writes. Every number is derived from those constants; none is written in
by hand.
- `packRequest` refuses a stroke whose pacing is over the budget, for
every sender and before anything leaves. The refusal names the per-op
doors (`issue-create.mjs`, `post-stamped.mjs`, `issue-transfer.mjs`,
`close-cards.mjs`, `label-write.mjs`) behind one `write-pace.mjs
--announce-batch N`. The executor refuses such a stroke again, with zero
requests (exit 2), in case a sender skipped the packer.
- `sendFleetWrite` adds the stroke's pacing to its completion ceiling
and prints one line saying so. A batched stroke is waited for. It is
never reported UNCONFIRMED for pausing as it was told to.
- If the runner's pace log cannot record the announcement, the stroke is
refused before the first write (exit 10). That is the same fail-closed
rule `paceWrite` applies.
`scripts/pm/write-pace.mjs` is only read: `announceBatch`,
`DEFAULT_BATCH_THRESHOLD`, `DEFAULT_BATCH_GAP_MS`, `fmtDuration`,
`unrecordableText` and `paceFilePath`. The workflow is untouched.
## Reach
Relay run
https://github.com/objectstack-ai/objectstack/actions/runs/39369675259
was created at 2026-10-20T04:27:32Z and completed at 04:28:28Z. It
created 15 issues on objectstack-ai/objectui:
- the first, objectstack-ai/objectui#13122, at 04:27:43Z;
- the second, objectstack-ai/objectui#13123, at 04:27:46Z, 3 s later;
- the last, objectstack-ai/objectui#13137, at 04:28:25Z.
That is 15 cards in 42 s from one identity. The job spent 11 s before
its first write. All times are read from the run and from the issues'
`created_at`.
## Runtime against the two ceilings
| content-creating actions in one stroke | pacing on the runner | seat
waits up to | job (10 min timeout) | outcome |
|---|---|---|---|---|
| 1 to 4 | rule ① only (3 s gaps) | 5 min (unchanged) | about 1 min |
unchanged |
| 5 | 2 min | 7 min | about 2.5 min | paced |
| 11 | 5 min | 10 min | about 5.5 min | paced: the largest stroke that
fits |
| 12 | 5.5 min | n/a | n/a | refused at the packer, and again at the
executor |
| 15 (the reach stroke) | 7 min | n/a | n/a | refused: send it through
`issue-create.mjs` behind `--announce-batch 15` |
| 20 (`MAX_ACTIONS`) | 9.5 min | n/a | over the timeout once overhead is
added | refused |
A landing stroke (`pr_ready` + `automerge_enable`) carries no
content-creating action. Every per-op door sends one card per stroke,
with at most one. Neither changes.
## Tests (HEAD 4a9257f)
Each new self-test battery has a pinned case floor, and each file's
battery-count floor is raised (execute 17, dispatch 21).
`execute.mjs --self-test`, battery "the batch gap on the runner" (10
cases, on an injected clock and a stubbed platform):
- The pin: 5 `issue_create` actions in one stroke land with gaps
`[30000,30000,30000,30000]`. Their sum equals `strokePacing`'s
`pacingMs`.
- Write-pace records one announcement before the first write.
- One action below the threshold keeps 3 s gaps.
- Non-creating writes after the batch go back to 3 s.
- A landing stroke is unchanged.
- 12 creates get exit 2 with ZERO requests, and the refusal names the
doors.
- An unrecordable announcement gets exit 10, after the sender-gate read
and nothing else.
`dispatch.mjs --self-test`, battery "the batch gap at the seat" (13
cases):
- The derived op set, plus the predicate on rows the test defines
itself.
- `strokePacing` at, below and above the threshold, counting a pull's
label leg.
- The budget holds at least a threshold-sized batch.
- Past `maxBatchWrites` the packer refuses and the CLI exits 2. At
`maxBatchWrites` the stroke packs.
- `RELAY_JOB_TIMEOUT_MS` equals the workflow's `timeout-minutes`.
- A batched run that completes after the ordinary ceiling is `success`,
and the ceiling grew by exactly the pacing. The same run with an
unbatched stroke is `timeout`.
Results:
| self-test | cases | batteries | before |
|---|---|---|---|
| `execute` | 138 | 17 | 128 / 16 |
| `dispatch` | 235 | 21 | 222 / 20 |
| `validate` | 158 | 11 | unchanged |
| `with-fleet` | 32 | n/a | all pass |
Reverse mutations, each through `scripts/ablation-replace.mjs`. Each
mutation landed (anchor hits 1 then 0, blob changed) and was restored
(blob equal to HEAD, `git diff HEAD` empty). Every one turned the
self-test red:
| mutation | what was removed | red cases | what it showed |
|---|---|---|---|
| M1 | the executor's `announceBatch(...)` call | execute 5 of 138 | the
pin's gaps became `[3000,3000,3000,3000]` (re-run on 4a9257f) |
| M2 | the seat's ceiling extension | dispatch 1 of 235 | `timeout` at
300000 |
| M3 | the packer's budget refusal | dispatch 2 of 235 | the packer case
and the CLI case |
| M4 | the executor's budget refusal | execute 2 of 138 | exit 0 with 13
requests |
| M5 | the `ANNOTATED_OPS` arm of `createsContent` | dispatch 2 of 235 |
`transfer` dropped out of the set |
## Gates (HEAD 4a9257f)
`node scripts/pm/dispatch-gates.mjs --commands` derived 40 commands for
this diff, the same as the dispatch order's list. All 40 exited 0, `pnpm
check:pm-dispatch-gates` included (fast tier, 1913 cases). Running
`--ran` with every exit code recorded reported `40 derived, 40 run, 0
NOT-MEASURED, 0 UNRUN`. Every other family is CI's.
## Acceptance notes
- The budget refusal lives in the packer and the executor, not in
`validate.mjs`'s `strokeErrors`. It is a rule about the run's pacing,
not the payload schema, and `validate.mjs` cannot import the seat's
ceiling without an import cycle. As a result, an over-budget stroke on
the runner is refused by the execute step (zero writes, exit 2, summary
written), not by the validate step.
- The batch runs from the stroke's first write through its last
content-creating one. A non-creating write placed before the first
create is therefore paced at the batch gap too, which errs on the safe
side.
- A run that stops early leaves its unspent batch in the runner's pace
log. The runner is ephemeral. A local `--payload-file` run on a
persistent home would keep the batch until its own expiry at most, the
same as any early-stopping `--announce-batch` caller.
- A batch split into strokes of 4 still gets rule ① gaps inside each
stroke. Across strokes, `sendFleetWrite` waits for each run before
returning. For a batch, the contract is still the seat-side
`--announce-batch` in front of the per-op doors; a direct `dispatch.mjs`
stroke is for landing. Noted, not filed.
- The branch is one commit behind `origin/main`. That commit,
a43405d, is a one-line doc edit to
`.claude/skills/pm-dispatch/references/filing-gate.md` and does not
overlap this diff, so it was not merged in. The queue rebuilds on
current main.
## 维护者速读(草稿)
**改了什么**:中继(fleet-write)执行器在同一笔里含 5 个及以上"创建内容"的动作(新建
issue、评论、PR、转移卡片)时,先在 runner 上宣告批次,随后这些写入每次间隔 30 秒(write-pace 规则
⑤)。发送端用同一公式算出这笔会暂停多久,相应延长等待;超出中继 job 10 分钟预算(单笔最多 11 个批次写)的笔在发送前被拒,并指向逐
op 工具。
**为什么改**:两个席位账号曾因短时间大量创建内容被平台限制,规则 ⑤ 正是为此加的截流;但中继路径漏了这道闸 —— 10-20 当天一笔
15 张卡 42 秒内落完。
**风险与代价(含回滚)**:一笔 5 张卡现在约需 2 分钟、11 张约 5 分钟,这是设计,不是变慢;单笔超过 11
个批次写会被拒,需改走逐 op 工具加 `--announce-batch`。落地笔(ready + automerge)与逐 op
工具的单卡笔不受影响。回滚:revert 本 PR 即恢复 3 秒间隔。
**席位意见**:
**你要做的**:无需操作;`scripts/pm/**` 非受管面,席位 ACCEPT 后翻 ready 入队。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01HEdP5tPhqQfzwRmKDxwn85)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3983965 commit 6387793
2 files changed
Lines changed: 402 additions & 15 deletions
0 commit comments