Skip to content

fleet-write: the executor applies write-pace rule ⑤ to a stroke of 5+ content-creating actions, and the seat budgets for it - #24810

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24755-relay-stroke-batch-gap
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24755-relay-stroke-batch-gap

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

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

The executor announces ONE rule-5 batch before the first write when a
stroke carries DEFAULT_BATCH_THRESHOLD or more content-creating actions,
so those writes leave DEFAULT_BATCH_GAP_MS apart instead of rule 1's 3 s.
The content-creating set is read off the op table (createsContent), the
pacing has one spelling (strokePacing) shared by the packer, the
run-poller and the executor; a stroke whose pacing the relay job cannot
hold is refused at the packer and again at the executor with zero writes,
naming the per-op doors; the seat waits for a batched run as long as the
runner is told to pause.

Claude-Session: https://claude.ai/code/session_01HEdP5tPhqQfzwRmKDxwn85
Co-authored-by: Claude <noreply@anthropic.com>
…fusal as an exit, never a crash

Claude-Session: https://claude.ai/code/session_01HEdP5tPhqQfzwRmKDxwn85
Co-authored-by: Claude <noreply@anthropic.com>
…troke, and pin the transcript line's order without a vacuous pass

Claude-Session: https://claude.ai/code/session_01HEdP5tPhqQfzwRmKDxwn85
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)· PR #24810 · domain:skills seat 1 · os-justin · session_01HEdP5tPhqQfzwRmKDxwn85 · 2026-10-20T07:37Z

  • 改了什么: 改两个中继脚本(fleet-write/execute.mjs + dispatch.mjs,+402/−15,其中两套注入时钟的自测与头部说明占大头):① 执行器在一笔含 ≥5 个「创建内容」动作(从 op 表机械推出:新建 issue、评论、PR、转移卡片)时,先在 runner 上宣告 write-pace 规则 ⑤ 的批次,随后这些写入每次间隔 30 秒;② 发送端用同一公式算出这笔要暂停多久,把等待上限相应延长并打印一行说明;③ 暂停超出中继 job 预算(10 分钟 job 超时 − 5 分钟常规等待 = 5 分钟,即单笔最多 11 个批次写)的笔在发送前就被拒,并点名逐 op 工具 + --announce-batch;执行器再拒一次(零请求)。落地笔(ready + automerge,0 个创建动作)与逐 op 工具的单卡笔不受影响。
  • 为什么改: 两个席位账号曾因短时间大量创建内容被平台限制,规则 ⑤ 正是您裁「截流应该要加」后加的闸;中继路径漏了它 —— 今天一笔 15 张卡 42 秒内落完(run 39369675259),QA 波次会话经您「同意」立卡 fleet-write: a relay stroke of up to 20 content-creating writes runs at the 3 s gap — write-pace rule ⑤ (batch gap 30 s) never applies on the relay path, so one stroke is a burst #24755。
  • 风险与代价(含回滚): 一笔 5 张卡约 2 分钟、11 张约 5 分钟,是设计不是变慢;超 11 的笔改走逐 op 工具。预算常量钉在 workflow 的 timeout-minutes 上(自测读盘比对,workflow 一动即红),⛔ 未改 workflow。回滚 revert 即恢复 3 秒间隔。dev 的一个开放问题 —— 预算是否改按实测开销(约 11 秒首写、56 秒总开销)放宽到 ~18 个 —— 本席答 A:不放宽:直发 dispatch.mjs 的笔本就只应是落地笔(rest-channel.md:16),大批量走逐 op 门,放宽只会多一个手调数字。
  • 席位意见: 接受。本席读了 createsContent(探 op 的首个请求是带 title/body 的 POST,或 ANNOTATED_OPS)、strokePacing(只数写请求、以最后一个创建动作的位次定批次长度、fits = pacingMs ≤ budgetMs)与两处拒收;与派发令的「(A) 必做 + 预算外按常量拒收、不写死数字、不改 workflow」一致;五个反向突变(去宣告 / 去延长等待 / 去打包拒收 / 去执行器拒收 / 去 ANNOTATED 臂)全部驱红。
  • 你要做的: 无。非受管面,本席 ACCEPT 后翻 ready 入队。

席位核对

  • 形态: draft、base main、首行 Fixes #24755、行首 Clause-②: no、assignee os-justin、skip-changeset 随创建同笔(REST 回读 labels = size/m, skip-changeset);2 文件 +402/−15;正文页脚会话 URL 形;闭合关键词只有首行一处。
  • 前提实测(席位,origin/main a43405d59 与分支 tip 4a9257fff): 文件面恰为 execute.mjs + dispatch.mjs,write-pace.mjs/ops.mjs/validate.mjs/workflow/post-stamped.mjs 未动(与兄弟卡 [finding] seat-side read-back judge (post-stamped classifyReadBack / fleet-write) answers NOT STORED on a correctly stored body while the proxy-side #N read expansion is active, and its remedy sends the seat to re-send the expanded text #24747 / PR fix(pm): read-back judge collapses the read path's own-repo reference expansion before calling a body mutated #24804 不相交);CONTENT_CREATING_OPS 由 op 表派生而非手列;RELAY_JOB_TIMEOUT_MS = 10 min 与 fleet-write.yml:64 一致并由自测钉住;执行器自测 138 例 / 17 battery(基线 128/16)、dispatch 235/21(基线 222/20)、validate 158/11 不变、check:pm-with-fleet 32 例全绿。
  • 门禁 check-runs(head 4a9257fff): 2026-10-20T07:37Z 43 条(39 名),30 success、9 skipped、0 failed、0 in_progress;Lint & Repo Gates 与 TypeScript Type Check 均 success 且钉 4a9257fff;check-expected-skips --pr 24810:OK — 11 skipped check-run(s), every one in the roster (exit 0);check-governed-merges:非受管面。dev 本地 40 条派生门禁全 0,--ran 40/40,钉 head(派发令第 41 条为空行)。
  • 接受的偏差: 预算基准改为「job 超时 − 发送端常规等待上限 DEFAULT_CEILING_MS」(PM 只读了 job 超时;dev 实测发送端等待上限先到),故「15 张一笔 7 分钟」的说法作废,改为 11 张上限;(B) 做成机械拒收而非提示(「落地笔」无需定义:0 个创建动作);拒收放在打包器与执行器而非 validate.mjs(该文件在文件面外且引入会成环 —— runner 上由 execute 步拒收);基点未合并 a43405d59(一行 filing-gate.md,无交集);四次 push;尾注与页脚按 AGENTS.md 无模型名形。
  • 复核记录: 非受管面,本记录即席位 TIER_DEFAULT 复核记录;dev 开放问题由本席答 A(见速读)。
  • 范围外去向: 无新指针;与 PR fix(pm): read-back judge collapses the read path's own-repo reference expansion before calling a body mutated #24804 的 dispatch.mjs 后续项(relay 行打印计数、notStoredText 折回提醒、传 payload.repo)在本 PR 合并后另起一张卡或折进下一张触 dispatch.mjs 的卡,记座位贴。
  • 落地条款: 非受管;全绿后本席 pr_ready + automerge_enable;本 PR 合并后本席自己的下一笔写入即走新代码(本席的笔 ≤2 个动作、0 或 1 个创建动作,不受影响)。

ACCEPT


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants