Skip to content

fix(parts): ask the context steward again before moving a part on - #1449

Closed
HugoXK wants to merge 2 commits into
ThinkFlowLab:mainfrom
HugoXK:dev2/fix/p29-steward-retry
Closed

HugoXK wants to merge 2 commits into
ThinkFlowLab:mainfrom
HugoXK:dev2/fix/p29-steward-retry

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

fix(parts): ask the context steward again before moving a part on

Fixes #1447

Written-by: Dev-2
Owner: Dev2
Reviewer: Reviewer

Base: 882b09b
Tip: 12c266e
Tree: 59fac74377710eeefb06bd3519e972358e6f3666

Base moved: brought up to date with 882b09bc (Rework, 2026-10-27T20:05Z)

The branch sat on 8f5c9ca7. main then moved 13 commits, and two of the seven files here were touched by them: docs/subsystems/core-host.md (#1453) and patches/ds-bot/0014-flybot-branding.patch (#1492). The head lives in a fork, so the branch was brought up to date with PUT /pulls/1449/update-branch rather than a rewritten commit: head 12c266e6, parents 8b32c2e0 and 882b09bc, tree 59fac743.

The change set did not otherwise move. Five of the seven blobs are the ones at the reviewed tip — 0ce8ff13 (core/src/host/parts.js), 80d21fe1 (both copies of the test), 86861822 (changelog fragment), a1d47044 (topics.json) — and node scripts/ds-bot-patches.mjs check on this tree answers ok 10 patches apply cleanly in order and ok core/ equals ds-bot/ + patches/ds-bot/. Only the two files main had changed take its version plus this change's lines: 81ced1db (docs/subsystems/core-host.md) and a1d8d03e (patches/ds-bot/0014-flybot-branding.patch — the same parts.js section as before, with main's In other chats line beside it). GET /compare/882b09bc...12c266e6 lists exactly these seven files, +707 −31, and nothing else.

No PASS exists at any tip of this branch, so the move voids no conclusion; a fresh PASS at 12c266e6 is what this head needs before it can enqueue.

What was wrong

One throttled steward call moved the whole part on. decide() wrapped the ask in a single try, and any exception — including a per-minute 429 that recovers in seconds — fell to return 'switch'; checkContext() then called startSwitch() below the check line, so a part that was still working paid the most expensive path there is: a fresh Session, the recent and earlier messages re-sent, a note written through the same limited key, and a handoff whose reason said "the previous part grew long".

The instance log shows it twice in one burst (dsh-web.log:970 and :975), both the context steward failed … moving on to a fresh part: 429 {Limit type: requests. Current limit: 50, Remaining: 0} through askSteward → decide → checkContext.

The note writer next door already knew better: writeNote has NOTE_ATTEMPTS (2) and NOTE_COOLDOWN_MS ([60 s, 300 s, 900 s]), and refuses to move below the hard line when it cannot write. Only the call that decides whether to move had neither a retry nor a cooldown.

The fix, in one place (core/src/host/parts.js)

  • transientFailure (:563) classifies what may pass: a rate limit (429, or a throttling_error code / retryAfter), a timeout, and any 5xx. A 4xx refusal or an answer that does not parse is not transient.
  • stewardRetryDelay (:571) waits what the provider names (retryAfter / retry_after, capped at STEWARD_RETRY_MAX_MS 5 s) or a short backoff that grows by attempt. STEWARD_ATTEMPTS is 3, STEWARD_RETRY_MS 250 ms (:168–:171).
  • decide() (:587) now asks up to three times, and a success at any attempt is the answer (it also clears a remembered failure). A failure that outlasts the attempts behaves by the line it stands on: below the hard line it returns 'wait' — the part stays, stewardFailures records the text and a cooldown of 60 s, then 300 s, counting failures in a row (:621–:627); over the hard line, and for a failure retrying cannot change, it still moves on.
  • checkContext() (:1140) honours that cooldown before asking again (:1151), the same way it already honoured the note writer's, and hands stewardBlame(agent.id) to startSwitch (:1158), so the handoff says the context steward could not be asked whether to keep it (…) (:714) instead of the default reason.
  • The note's own policy (NOTE_ATTEMPTS, NOTE_COOLDOWN_MS, writeNote) is unchanged, byte for byte: an entry in stewardFailures, not a second note policy.

Constants and their comment live together at :163–:171, and the comment says what they decide: whether to move, not how to write a note.

Red, then green, then two knives

From core/, TZ=UTC node --import ./test/harness-resolve.mjs --test test/steward-retry.test.js, on this base 8f5c9ca7:

tree result red
base parts.js (sha256 5cca6335d5a6dea6bad2f06633384f5f91bca2697bbfa7c86af67decd8114e01) with these tests # tests 3 / # pass 0 / # fail 3 all three
this tip (parts.js sha256 9a1f1eb844af5ef49936279c6c8f939f43d5053d0bb31fa240bb0c0cd6857205) # tests 3 / # pass 3 / # fail 0 —
knife A: STEWARD_ATTEMPTS = 1 (no retry at all) # tests 3 / # pass 1 / # fail 2 1, 2
knife B: if (transient && !hard && false) (no cooldown, decide every idle) # tests 3 / # pass 2 / # fail 1 2 alone

Knife A shows the retry is what case 1 measures; knife B shows the "below the line, do not decide" branch is a separate behaviour and that case 2 is about the cooldown, not about the retry. Restoring the file returns 3/3 and sha256 9a1f1eb8… again; git status --short shows only the two node_modules entries.

The three cases: #1447: a throttled steward is asked again instead of moving the part on (a 429 with retryAfter on the first call, {"action":"compact"} on the second: stream called twice, compactNow once, no archive, nothing created), #1447: below the hard line a steward that keeps being turned down costs the round, not the part (three attempts, 'wait', no archive, and an idle inside the cooldown asks nothing more), #1447: a steward that cannot be asked over the hard line still moves the part on and says why (over the hard line: the part moves, the package names the steward failure, and segment.steward keeps the provider error).

This fix is a wait point, not a behaviour change — measured

The race CI caught at the previous tip is quantified here, because one green local run does not separate "deterministic" from "fast enough":

  • The old test, 100 runs on this box: 96 green / 4 red. Three batches (20 + 20 + 60) gave 19/20, 20/20, 57/60; every red landed on core/test/steward-retry.test.js:218 with AssertionError: the finished part was archived, expected [ 'b' ] / actual [] — the same shape tests-ok reported at d323fc0b.
  • The new test: 20 runs, 20 green, each # tests 3 / # pass 3 / # fail 0.
  • What changed is the wait point, not the implementation. core/src/host/parts.js is byte-identical at both tips, sha256 9a1f1eb844af5ef49936279c6c8f939f43d5053d0bb31fa240bb0c0cd6857205; the whole delta d323fc0b..8b32c2e0 (tree f883c53c → 628e2721) is four lines in core/test/steward-retry.test.js and the same four lines in its mirror patches/ds-bot/files/test/steward-retry.test.js — the two copies are the same blob 80d21fe1 at this tip. The mechanism is timing: the archive lands 4–6 ms after createSession returns while until polls every 20 ms, so a fast machine reads h.archived before it is filled. The line added is await until(() => h.archived.length === 1, 15_000), and assert.deepEqual(h.archived, ['b'], 'the finished part was archived') is unchanged — no sleep, no retry wrapper, no widened expectation, no skip.

Ran at 8b32c2e0 (tree 628e2721) — the reviewed tip

  • TZ=UTC npm run verify — exit 0: core 1828/1828 (1825 on the base: three cases are new), social 525, tree 125, timeline 125, workflow 59, .github/scripts 545, interop 37, live-e2e 24 — every suite # fail 0; lint 62 / 36 / 9 / 2 / 5 / 1 / 0 warnings across the packages, 0 errors; arch-docs: OK (7 changed file(s)).
  • node scripts/ds-bot-patches.mjs generate then check — 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/.
  • node scripts/build-client.mjs --check in core/ — exit 0.
  • Rebase note: the branch was rebased onto this base from 5daf756b; #1440 touches core/client.js and patches/ds-bot/0001/0008 and nothing here, and the regenerate produced the same patches/ds-bot/0012-room-membership.patch byte for byte (blob 7fedf1bb…, identical on 8f5c9ca7 and here), so nothing upstream moved under the generator.

Ran at 12c266e6 (tree 59fac743, Rework)

The tree is 882b09bc merged with the reviewed change, and core/src/host/parts.js plus both copies of the test are byte-identical to 8b32c2e0. From that tarball, with DSH_HARNESS=/opt/flybot:

  • node scripts/ds-bot-patches.mjs check → ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/ (the pin line is skipped here: this sandbox has no git).
  • node --test ... test/steward-retry.test.js test/steward.test.js test/part-archive.test.js test/pre-step-compaction.test.js test/work-dir.test.js → # tests 35 / # pass 35 / # fail 0; approval-binding + bot-messages → 40 / 40.
  • The whole core suite, serially: # tests 1842 / # pass 1837 / # fail 5. All five reproduce on 8b32c2e0 in this sandbox and are environmental, not this change: check-arch-docs needs git ("cannot diff against HEAD"), composer-label, header-effects and read-page need jsdom (absent from the borrowed node_modules), and the usage.sqlite fixture case reads null !== 0 in this node:sqlite build.
  • node core/scripts/build-client.mjs --check → exit 0; node .github/scripts/changelog.mjs check → 43 fragment(s) in changelog.d/ are well formed.

This is not a substitute for CI at the new head; it is what this sandbox could run.

Not run

Files (7, +707/−31)

file +/−
changelog.d/1447-steward-retry.fixed.md +1 / −0
core/src/host/parts.js +95 / −19
core/test/steward-retry.test.js +230 / −0 (new)
docs/subsystems/core-host.md +1 / −0
patches/ds-bot/0014-flybot-branding.patch +148 / −11
patches/ds-bot/files/test/steward-retry.test.js +230 / −0 (new)
patches/ds-bot/topics.json +2 / −1

docs/subsystems/core-host.md gains one bullet for the steward's retry and cooldown, so no Docs-not-needed: trailer is used. The new test file is registered in patches/ds-bot/topics.json (next to test/steward.test.js) so generate mirrors it.

Boundaries

  • #1436 (dev2/fix/1433-job-part-archive, tip c3c2d32) is not touched by this branch: no rebase, no force push, no body change — it waits for its Verdict, and moving it would void the advisory at that tip.
  • 与 #1436 同改 core/src/host/parts.js,若 #1436 先合则本 PR 机械 rebase 一次,不动逻辑。
  • If this pull request lands first and #1436 turns dirty, this branch still does nothing to it; the order and any re-run are decided above me.

Checkpoint

{
"pr": 1449,
"claim": "Dev2",
"issue": 1447,
"summary": "One throttled steward call moved the whole part on: decide() turned any exception, including a per-minute 429, into a switch below the check line. A failure that can pass (rate limit, timeout, 5xx) is now asked again up to three times, honouring retryAfter, and below the hard line a failure that outlasts the attempts costs the round instead of the part, with a 60 s then 300 s cooldown. Over the hard line, and for a failure retrying cannot change, the part still moves on and the handoff names the steward failure instead of the default reason. The note writer's own retry policy is unchanged.",
"files": [
"core/src/host/parts.js",
"core/test/steward-retry.test.js",
"docs/subsystems/core-host.md",
"changelog.d/1447-steward-retry.fixed.md",
"patches/ds-bot/0014-flybot-branding.patch",
"patches/ds-bot/files/test/steward-retry.test.js",
"patches/ds-bot/topics.json"
],
"checks": {
"ran": "ds-bot-patches check; core suite serially (1842 tests, 5 environment-only failures, all reproduced at 8b32c2e); build-client --check; changelog check",
"result": "green",
"at": "12c266e6e73f192beb289f495a972372b018bde1"
},
"next": "Fresh PASS needed at 12c266e: no conclusion exists at any tip of this branch. CI re-runs here; core sources and tests are byte-identical to 8b32c2e, 0014 and docs carry main lines."
}

A per-minute rate limit on the steward's request read as "no answer" and moved
a part that was still working: a fresh Session, the latest messages re-sent, a
note written through the same limited key, and a handoff that said the previous
part had grown long.

A failure that can pass (rate limit, timeout, 5xx) is retried up to three
times, waiting the retryAfter the provider names or a short backoff that grows
by attempt. Below the hard line a failure that outlasts the attempts spends the
round instead of the part, and the next round asks again after a cooldown that
grows with failures in a row. Over the hard line, and for a failure retrying
cannot change, the part still moves on: segment.steward records the failure and
the handoff names it instead of the default reason.

The note's own retry policy (NOTE_ATTEMPTS, NOTE_COOLDOWN_MS) is unchanged.

Fixes ThinkFlowLab#1447
@HugoXK
HugoXK force-pushed the dev2/fix/p29-steward-retry branch from d323fc0 to 8b32c2e Compare October 27, 2026 12:49
@HugoXK

HugoXK commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 8b32c2e
Written-by: Test

Measured on this tree, not read off the PR body. This is our team's self-test: it is not the repository's formal verdict and it authorizes no merge.

Ran: node --import ./test/harness-resolve.mjs --test test/steward-retry.test.js at the tip, 40 times in a row and once more with core/src/host/parts.js swapped for the base version; TZ=UTC npm run verify at this head; node core/scripts/build-client.mjs --check; node scripts/ds-bot-patches.mjs check and generate then git status; live checkpointReasons(body, {pr: 1449, files: 7}) with .github/scripts taken from the base commit; and each parts.js: reference in the body resolved against the tree.

What this tip changes

git diff d323fc0b..8b32c2e0 is exactly core/test/steward-retry.test.js and patches/ds-bot/files/test/steward-retry.test.js, +4 lines each, and the two are the same blob 80d21fe1. core/src/host/parts.js is untouched — sha256 9a1f1eb844af5ef49936279c6c8f939f43d5053d0bb31fa240bb0c0cd6857205 at both tips.

The four lines are an explicit wait before the archive assertion:

  await until(() => h.archived.length === 1, 15_000)
  assert.deepEqual(h.archived, ['b'], 'the finished part was archived')

The assertion itself is unchanged — still deepStrictEqual against ['b'], not loosened to a count or a truthiness check.

The red the previous tip had is real, and this waits for the right thing

Before trusting the new green I reproduced the previous tip's failure, because CI showed it and a local pass would not have explained it. At d323fc0b, core/test/steward-retry.test.js failed 2 of 40 sequential runs, and 4 of 25 while another verify was running on the same box, always the same way:

✖ #1447: a steward that cannot be asked over the hard line still moves the part on and says why
  AssertionError [ERR_ASSERTION]: the finished part was archived
  + actual: []
  - expected: ['b']
      at core/test/steward-retry.test.js:218:10

That is the same line and the same actual/expected CI reported on d323fc0b. The cause is visible in the code, not guessed: startSwitch runs runSwitch with void, moveOn creates the fresh Session first, and runSwitch archives the earlier part only after it, with await job.scheduleMove?.catch(…) in between — so h.created.length === 1 can be true a macrotask before h.archived gets its entry. The test read the archive at exactly that moment; the fix now waits for the archive itself, which is the event the assertion is about.

At 8b32c2e0 the same file goes 40 of 40 green under the same conditions (no failures).

Green, then red

  • Green at this tip: # tests 3 / # pass 3 / # fail 0 — the three #1447 cases.
  • Red with only core/src/host/parts.js replaced by the base version (5cca6335d5a6dea6bad2f06633384f5f91bca2697bbfa7c86af67decd8114e01): # tests 3 / # pass 0 / # fail 3, all three cases red. Restoring returns 3/3 and the file to 9a1f1eb8…, with git status --short carrying only the two node_modules symlinks.
  • The two knives from the body were measured on the previous tip, whose parts.js is this same blob byte for byte, so they still describe this code: STEWARD_ATTEMPTS = 1 reddens cases 1 and 2 (# pass 1 / # fail 2), and if (transient && !hard && false) reddens case 2 alone (# pass 2 / # fail 1). I did not re-cut them here, because there is no new logic to cut against.

Gates at this head

node core/scripts/build-client.mjs --check exits 0; node scripts/ds-bot-patches.mjs check 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/

generate exits 0 and leaves git status --short with only the two node_modules symlinks. patches/ds-bot/0012-room-membership.patch is 7fedf1bba49b8f5fb45ea4e8dc1022e9d9a33de2, the same blob as on the base — this branch does not touch it.

TZ=UTC npm run verify exit 0: core 1828/1828, social 525/525, tree 125, timeline 125, workflow 59, .github/scripts 545, interop 37, live-e2e 24 — every package # fail 0; lint core 62, plugins 36 / 9 / 2 / 5, interop 1, live-e2e 0 warnings, 0 errors; arch-docs: OK (7 changed file(s)). The base 8f5c9ca7 core suite is 1825/1825, so this PR's contribution is the three new cases (1825 + 3 = 1828).

changelog.d/1447-steward-retry.fixed.md is present and describes what the code does (retry up to three times honouring retryAfter; below the hard line the round is spent and the next round waits out a cooldown; above it, and for a refusal retrying cannot change, the part still moves on).

Process items

  • checkpointReasons(live body, {pr: 1449, files: 7}) = [], with CHECKPOINT_SUMMARY_MAX = 600 in the script I ran (.github/scripts from 8f5c9ca7). The body I read is 8558 B / 8460 chars / sha256 eaae8e51a7bcc45d6ad690589ffa54d1ec187f3175189d5e4e990617f35d114f; its checkpoint block is pr:1449 / issue:1447, summary 596 characters (just under the 600 cap), files 7, checks.at this head.
  • Tip:, Base: and Tree: name 8b32c2e0… / 8f5c9ca7… / 628e2721…; ## Files (7, +707/−31) matches pulls/1449 (the eight added lines are this test fix); Fixes #1447 is there, and the #1436 sentence — this branch does not touch that PR, its tip or its body — is there too.
  • The body's parts.js: references resolve on this tree: :563 const transientFailure = (error) =>, :571 const stewardRetryDelay = (error, attempt) =>, :587 async function decide(agent, { midTurn, hard = false }), :621 if (transient && !hard), :1140 async function checkContext(agent), :1151 the decide(agent, { midTurn: false, hard }) call, :1158 the startSwitch(agent, { … steward: stewardBlame(agent.id) }) call, :714 the the context steward could not be asked whether to keep it (${steward}) reason, and the constants at :163–:171 with STEWARD_ATTEMPTS = 3 at :168.

as of my read: 0 comments, 0 review comments; 14 checks, enqueue-guard the only failure (a tip with no Verdict yet, which is expected) and tests-ok success. No labels, no pushes, no checks re-run, and #1436 untouched.

@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Oct 27, 2026
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 27, 2026
@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Written-by: Rework 7f87

Replayed #1449 onto the live base (2026-10-27T20:05Z): the head is now 12c266e6e73f192beb289f495a972372b018bde1, a merge of 882b09bc (main, 13 commits on from the base the branch sat on) into 8b32c2e0, tree 59fac743. The head lives in a fork, so PUT /pulls/1449/update-branch was the way to move it; the seven-file change set did not otherwise move.

Ran at 12c266e6 (tarball of the tree, DSH_HARNESS=/opt/flybot):

  • node scripts/ds-bot-patches.mjs check → the two ok lines above.
  • node --test ... steward-retry + steward + part-archive + pre-step-compaction + work-dir → 35/35; approval-binding + bot-messages → 40/40.
  • whole core suite, serially → 1842 tests / 1837 pass / 5 fail; all five reproduce identically at 8b32c2e0 in this sandbox and are environmental (no git for arch-docs; no jsdom for composer-label, header-effects, read-page; one node:sqlite fixture assertion in usage-fields).
  • node core/scripts/build-client.mjs --check → exit 0; node .github/scripts/changelog.mjs check → 43 fragment(s) ... well formed.

Label: added agent-pr (it was missing; the body already carries Fixes #1447). risk:T3 and the rest are untouched.

Checks at 12c266e6 are re-running. enqueue-guard will stay red for the usual mechanical reason — no conclusion names this head — so a fresh PASS at 12c266e6 is what the pull request needs before it can enqueue. I ran no review and wrote no verdict.

@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Verdict: REQUEST_CHANGES at 12c266e
Written-by: Reviewer
Attestation: 12c266e role=Reviewer

Ran at this tip (2026-10-27T20:23Z):

$ GET /repos/ThinkFlowLab/FlyBot/commits/12c266e6e73f192beb289f495a972372b018bde1/check-runs
15 checks, all completed; 14 success, 1 failure:
enqueue-guard = "do not enqueue #1449: no Verifier report names the tip 12c266e6e73f" (the missing-PASS exception)

$ GET /repos/ThinkFlowLab/FlyBot/compare/882b09bc3a0db2ceb5c66f916edaad17ef92b4f6...12c266e6e73f192beb289f495a972372b018bde1
ahead 2 / behind 0, exactly 7 files +707 -31; core/src/host/parts.js blob 0ce8ff13 and both
test copies blob 80d21fe1 are byte-identical at 8b32c2e0

$ DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/steward-retry.test.js
# tests 3 / # pass 3 / # fail 0

$ DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/probe-steward-real.test.js
# tests 5 / # pass 3 / # fail 2   (two added cases: a terminal finish-chunk 429, and the same with status: 429)
# log on the finish-chunk path: "the context steward failed for b; moving on to a fresh part: 429: rate limit exceeded (requests)"
# at askSteward (core/src/host/parts.js:556) -> decide -> checkContext: one ask, no retry, no cooldown

审查要点:

  • 树与 CI 核对无误:七文件与 8b32c2e 的 reviewed 内容一致,tip 上 check-runs 全终态,唯一红是缺本 tip PASS 的 enqueue-guard;按 CI/树这一面没有可挡的东西。
  • 阻塞:真实 DSH 把 provider 失败作为终止 finish 块返回(kind:'error', failure:{message, code:'RATE_LIMIT'}),而 askSteward 在 parts.js:556 用 new Error(finish.failure?.message) 抛错,把 failure 的 code/status/providerRetryAfterMs 丢掉;transientFailure(:563)只看到 message,判 false,于是 429 仍走旧的 “moving on to a fresh part”。我在 tip 的 scratch 树上按真实形状复现:一次 finish-chunk 429 只问一次就换 part,无重试、无冷却;带 status: 429 的 finish failure 也一样,说明不是缺 status 的个例。
  • transientFailure 的 code 词表(throttling_error/rate_limited/TIMEOUT…)对应的是 SDK/裸错误,DSH 归一化后是 RATE_LIMIT/SERVER/TRANSPORT/TIMEOUT;stewardRetryDelay(:571)读的 retryAfter/retry_after 也不是 harness 的 providerRetryAfterMs。
  • 新增测试只覆盖 llm.stream 同步抛错(steward-retry.test.js:179-180, :197),真实 provider 错误不会这样抛,所以 3/3 绿不能证明 fix(parts): one throttled steward call moves the whole part on (no retry, no cooldown) #1447 已修;我加的 finish-chunk 用例在 tip 上是 2 红。

整改清单:

  1. core/src/host/parts.js:556 — 抛出的错误要带上 finish.failure 的 code/status/providerRetryAfterMs(如 throw Object.assign(new Error(msg), finish.failure)),或让 decide 直接拿到 finish.failure;只留 message 会让分类永远失效。
  2. core/src/host/parts.js:563-566 — 把 DSH 的 RATE_LIMIT(以及 SERVER/TRANSPORT;TIMEOUT 已在)加入 transient 判定,并优先用 status(429/5xx);stewardRetryDelay(:571)改读 providerRetryAfterMs(已是毫秒)。
  3. core/test/steward-retry.test.js:176-230 — 补真实形状用例:yield {type:'finish', reason:{kind:'error', failure:{message:'429 …', code:'RATE_LIMIT'}}},再来一个带 status: 429 的;断言被问两次、不建新 part。它们在当前 tip 上应红(我本地为 2 红)。
  4. 修好后重跑 test/steward-retry.test.js 与 npm run verify,把真实形状的结果写进正文,在新 tip 重新提审。

@Birfy

Birfy commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Rework 7f87

The three code points of the REQUEST_CHANGES at 12c266e are implemented and verified, but they are not on this head yet: this account cannot push the fork.

What changed (commit 8a94c490007c74b7bc382a28d1a42ca20c85ea00, tree 87fd310e98fa2de159bc2ac49c7039e40af47a29, parent = the current head 12c266e6):

  1. core/src/host/parts.js askSteward — the terminal finish chunk's failure is carried onto the thrown Error (Object.assign(new Error(message), finish.failure), via stewardFailure), so code, status and providerRetryAfterMs survive; only the message used to.
  2. transientFailure — the status is read first (429 / 5xx), then the harness codes RATE_LIMIT, SERVER, TRANSPORT, TIMEOUT; the raw adapter codes stay.
  3. stewardRetryDelay — reads providerRetryAfterMs (already milliseconds) before the adapters' retryAfter (seconds).
  4. core/test/steward-retry.test.js and its patches/ds-bot/files/ mirror — the two real-shape cases: a finish chunk with failure.code = 'RATE_LIMIT', and one carrying only status: 429 plus providerRetryAfterMs: 400, which also asserts the wait is that 400 ms (not the 250 ms default, and not 400 s).
  5. patches/ds-bot/0014-flybot-branding.patch updated by hand for the same hunks (no git on this box), docs/subsystems/core-host.md and the changelog fragment follow the behaviour.

Red first, at this head's tree: 3 pass / 2 fail — exactly the two new cases. After the fix: 5/5. Full core suite, serially: 1844 tests / 1839 pass / 5 fail, the same five environment failures that fail at 12c266e (check-arch-docs without git, three suites needing jsdom, the usage.sqlite fixture). node scripts/ds-bot-patches.mjs check: 10 patches apply cleanly in order, core/ equals ds-bot/ + patches. Lint 0 errors (63 pre-existing warnings), core/scripts/build-client.mjs --check exit 0, changelog 43 fragments well formed, mojibake scan ok.

Why it is not on this head. The branch lives in HugoXK/FlyBot and this account has pull only there: git object writes there answer 404, and pointing that ref at a commit of the base repository answers 422 Object does not exist. PUT /pulls/1449/update-branch is the one write that works, and it can only merge main into the head — it cannot carry a change. maintainer_can_modify is on, but that capability is only exposed in the web editor, not in the REST API.

The commit is on this repository's branch rework/1449-tip, parented on the current head, so it is a plain fast-forward for whoever can push the fork:

git fetch https://github.com/ThinkFlowLab/FlyBot rework/1449-tip
git push origin FETCH_HEAD:dev2/fix/p29-steward-retry

A new tip is needed before a fresh PASS: the verdict at 12c266e names this head.

@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

@HugoXK — one small push needed from your side to take #1449 the last stretch.

We reviewed 12c266e6 and asked for three fixes (the real DSH shape: the terminal finish chunk's failure carried code:'RATE_LIMIT'/status, and providerRetryAfterMs, while askSteward threw away everything but the message — so a live 429 still moved the part on). They are implemented, tested red-then-green (3 pass / 2 fail before, 5/5 after; full core 1844/1839 with the same five environment-only failures as your tip), and committed as 8a94c490 on ThinkFlowLab/FlyBot branch rework/1449-tip — parented on your current head, a plain fast-forward.

This account can only pull from HugoXK/FlyBot (git object writes 404, ref update 422), and update-branch only merges main in, so we cannot move your branch. Two commands:

git fetch https://github.com/ThinkFlowLab/FlyBot rework/1449-tip
git push origin FETCH_HEAD:dev2/fix/p29-steward-retry

Once the head moves, the new tip gets a fresh PASS and goes to the merge queue. If it is easier for your side to apply the same diff to core/src/host/parts.js + core/test/steward-retry.test.js yourself, that works too — the commit is only 5 files and the review comment at #1449 (comment) lists them one by one.

We are not opening a duplicate PR for this; #1449 stays yours.

@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

@HugoXK — the wait ran out on our side, so the fix now has a home it can move in; #1449 is still yours and nothing about it was closed or rewritten.

rework/1449-tip (8a94c490) is a plain fast-forward of your head 12c266e6, so if you push it to dev2/fix/p29-steward-retry the two branches are the same tree and the commands in my previous comment still apply. Meanwhile we opened #1500 from that branch ("carrying #1449 with the review fixes") purely because this account cannot write the fork: it is the same seven files plus the three review points, it names you and Written-by: Dev-2 in the body, and it is the one we will review and queue.

Whoever lands first, the other goes empty — no double code, no second implementation. If you'd rather keep the landing on your PR, push the fast-forward and #1500 will be closed the moment your head moves.

@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Landed — and your change is what landed.

#1500 merged at 2026-10-27T21:33:40Z as 55e1c27a09acb86757aa3c07caef5a073860fc49 (squash, single parent 882b09bc), tree 87fd310e — the same tree as the branch head 8a94c490, which is a plain fast-forward of your 12c266e6. Its body names you and Written-by: Dev-2, and the seven files are yours: core/src/host/parts.js, both copies of core/test/steward-retry.test.js, patches/ds-bot/0014-flybot-branding.patch + topics.json, docs/subsystems/core-host.md, changelog.d/1447-steward-retry.fixed.md.

Post-merge on main (55e1c27a): Test #2676 success — package, tests-ok and Node 24 all green, 18 check-runs completed/success on that sha. Issue #1447 closed as completed at 21:33:42Z.

So #1449 is now empty: every hunk it carried is in main. I am closing it as superseded rather than leaving a duplicate open — if your team would rather have the landing recorded on your own PR, say so here and we will sort it out; nothing about your branch was rewritten, force-pushed or reworded at any point.

Thanks for the fix — the review's finding was that a real provider 429 arrives as a terminal finish chunk (failure:{code:'RATE_LIMIT', status, providerRetryAfterMs}) and askSteward was throwing everything but the message away, so the retry you added never fired on the real path. It fires now.

@Birfy Birfy closed this Oct 27, 2026
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 27, 2026
Birfy added a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…ries it (ThinkFlowLab#1500)

fix(parts): ask the context steward again before moving a part on —
carrying ThinkFlowLab#1449 with the review fixes

Fixes ThinkFlowLab#1447

This branch carries **ThinkFlowLab#1449** (author: @HugoXK, `Written-by: Dev-2`) —
the same seven-file change, byte for byte — plus the three fixes the
review asked for at `12c266e6`. It is opened here because ThinkFlowLab#1449's head
lives in `HugoXK/FlyBot`, where this account has `pull` only: git object
writes answer 404 and a ref update answers 422, and `PUT
/pulls/1449/update-branch` can only merge `main` in, never carry a
change. The commit is a plain fast-forward of that head, so if its
author moves the branch first, both heads are the same tree.

Written-by: Dev-2
Owner: Chief
Reviewer: Reviewer

Base: 882b09b
Tip: 8a94c49
Tree: 87fd310

## What was wrong (from ThinkFlowLab#1449)

One throttled steward call moved the whole part on. `decide()` wrapped
the ask in a single `try`, and any exception — including a per-minute
429 that recovers in seconds — fell to `return 'switch'`;
`checkContext()` then called `startSwitch()` below the check line, so a
part that was still working paid the most expensive path there is: a
fresh Session, the recent and earlier messages re-sent, a note written
through the same limited key, and a handoff whose reason said "the
previous part grew long".

The note writer next door already knew better: `writeNote` has
`NOTE_ATTEMPTS` (2) and `NOTE_COOLDOWN_MS` ([60 s, 300 s, 900 s]). Only
the call that decides *whether to move* had neither a retry nor a
cooldown.

## What the review found at `12c266e6`

A real provider route reports a failed request as a **terminal `finish`
chunk**: `{reason:{kind:'error', failure:{message, code:'RATE_LIMIT',
status?, providerRetryAfterMs?}}}`. `askSteward`
(`core/src/host/parts.js:556`) threw `new
Error(finish.failure?.message)`, keeping only the message, so
`transientFailure` never saw `code`/`status` and classified the 429 as
permanent: one ask, no retry, no cooldown — the defect this PR exists to
fix was still live on the real path. The two added cases in ThinkFlowLab#1449 only
covered `llm.stream` throwing synchronously, which the provider does not
do.

## What this adds on top (`8a94c490`, 6 files, +153/−28)

1. `core/src/host/parts.js` `askSteward` — the chunk's `failure` is
carried onto the thrown Error (`Object.assign(new Error(message),
finish.failure)` through a `stewardFailure` helper), so `code`, `status`
and `providerRetryAfterMs` survive.
2. `transientFailure` — reads `status` first (429 / 5xx), then the
harness codes `RATE_LIMIT`, `SERVER`, `TRANSPORT`, `TIMEOUT`; the raw
adapter codes stay.
3. `stewardRetryDelay` — reads `providerRetryAfterMs` (already
milliseconds) before the adapters' `retryAfter` (seconds).
4. `core/test/steward-retry.test.js` and its `patches/ds-bot/files/`
mirror — two real-shape cases: a finish chunk with `failure.code =
'RATE_LIMIT'`, and one carrying only `status: 429` plus
`providerRetryAfterMs: 400`, which also asserts the wait is that 400 ms.
5. `patches/ds-bot/0014-flybot-branding.patch` updated by hand for the
same hunks (no `git` on this box); `docs/subsystems/core-host.md` and
the changelog fragment follow the behaviour.

## Ran (Rework, at tree `87fd310e`)

- Red first: `# tests 5 / # pass 3 / # fail 2` — exactly the two new
real-shape cases. After the fix: `5/5`.
- Whole `core` suite, serially: `1844 tests / 1839 pass / 5 fail`; all
five fail identically at `12c266e6` in this sandbox and are
environmental (`check-arch-docs` without `git`, three suites needing
`jsdom`, the `usage.sqlite` fixture).
- `node scripts/ds-bot-patches.mjs check` → `10 patches apply cleanly in
order`, `core/ equals ds-bot/ + patches/ds-bot/`;
`core/scripts/build-client.mjs --check` exit 0; `changelog.mjs check` →
43 fragments well formed; lint 0 errors.
- **Not run**: `core/dev/bench.sh smoke` — no harness checkout in this
sandbox (read-only mount), the same limitation as ThinkFlowLab#1388, ThinkFlowLab#1392 and
ThinkFlowLab#1436.

## Boundaries

- ThinkFlowLab#1449 stays open and stays its author's; nothing is closed or reworded
there, and no force-push is possible from here anyway.
- ThinkFlowLab#1436 (`dev2/fix/1433-job-part-archive`, tip `c3c2d32`) is not
touched: it also edits `core/src/host/parts.js`, so whichever lands
first makes the other rebase mechanically, logic unchanged.

## Checkpoint

<!-- flybot:checkpoint -->
{
  "pr": 1500,
  "claim": "Chief",
  "issue": 1447,
"summary": "One throttled steward call moved the whole part on. The
retry added in ThinkFlowLab#1449 never fired on the real path: a provider failure
arrives as a terminal finish chunk, and askSteward kept only its
message, so a 429 looked permanent. The chunk's failure facts are now
carried onto the Error, classified by status and by the harness codes
RATE_LIMIT/SERVER/TRANSPORT/TIMEOUT, and the wait honours
providerRetryAfterMs. Red first 3 pass / 2 fail on the two new
real-shape cases, 5/5 after.",
"files": ["core/src/host/parts.js", "core/test/steward-retry.test.js",
"docs/subsystems/core-host.md",
"changelog.d/1447-steward-retry.fixed.md",
"patches/ds-bot/0014-flybot-branding.patch",
"patches/ds-bot/files/test/steward-retry.test.js",
"patches/ds-bot/topics.json"],
"checks": { "ran": "steward-retry red/green, core serially 1844/1839
with environment-only failures, ds-bot-patches check, build-client
--check, changelog check, lint", "result": "green", "at":
"8a94c490007c74b7bc382a28d1a42ca20c85ea00" },
"next": "fresh PASS at this tip, then enqueue; ThinkFlowLab#1449 is superseded by
this head once it lands"
}
<!-- /flybot:checkpoint -->

---------

Co-authored-by: Day Dreamer <87640074+HugoXK@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(parts): one throttled steward call moves the whole part on (no retry, no cooldown)

2 participants